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
@@ -59,8 +59,15 @@ module SupportDesk
59
59
  # readonly record refuses to be destroyed. Deleting a ticket is the one
60
60
  # thing that takes its timeline with it, and it needs no callbacks.
61
61
  has_many :events, class_name: "SupportDesk::Event", inverse_of: :ticket, dependent: :delete_all
62
+ has_many :message_registrations, class_name: "SupportDesk::MessageRegistration", dependent: :delete_all
62
63
  has_many :messages, through: :conversation, source: :messages
63
64
 
65
+ # The assistant surface — the turn, the policy gates, her two verbs and
66
+ # the two exits — in its own file. The GATES inside the verbs below stay
67
+ # here, next to the verbs they guard: a reader looking at `close!` has to
68
+ # see what stops a machine closing a case.
69
+ include Assistance
70
+
64
71
  # Persist only the path. Resolving behavior needs this ticket's desk;
65
72
  # an attribute caster has no record context and cannot choose the tree.
66
73
  attribute :topic, Topic::Type.new
@@ -224,12 +231,14 @@ module SupportDesk
224
231
  # something to infer from Current.actor, and an agent passed as their
225
232
  # own requester is asking for help, not writing to themselves.
226
233
  by_support = !same_actor?(opener, requester)
227
- ensure_opener!(opener) if by_support
234
+ ensure_opener!(opener, desk) if by_support
228
235
 
229
236
  # The subject is checked BEFORE the topic: "this isn't supportable" is
230
237
  # the useful error, and an unsupportable record has no topic to find.
231
238
  validate_subject!(about, requester)
232
239
  node = resolve_topic!(topic, about, desk, requester, about, by_support: by_support)
240
+ # The topic is what caps her, so the cap is checked once it is known.
241
+ ensure_assistant_may_open!(opener, requester, desk, node) if by_support && SupportDesk.ai_actor?(opener)
233
242
 
234
243
  cardinality = cardinality_key_for(requester: requester, subject: about, topic: node)
235
244
  existing = existing_for(requester: requester, desk: desk, subject: about, topic: node)
@@ -333,10 +342,14 @@ module SupportDesk
333
342
 
334
343
  # A loaded instance may predate revocation or deletion. Check the row,
335
344
  # just as requester eligibility does; this does not serialize revocation.
345
+ #
346
+ # Returns the FRESH record, so a caller that goes on to read a runtime
347
+ # flag off it (the assistant's `active?`, which the policy reads) is
348
+ # reading the row and not the copy it was handed (R9).
336
349
  def ensure_agent_record!(record) # :nodoc:
337
350
  if record.respond_to?(:persisted?) && record.persisted? && record.respond_to?(:support_agent?)
338
351
  current = record.class.uncached { record.class.find_by(id: record.id) }
339
- return if current&.support_agent?
352
+ return current if current&.support_agent?
340
353
  end
341
354
 
342
355
  raise NotAnAgent, "#{record.class} is not a currently eligible, persisted support agent — " \
@@ -362,7 +375,7 @@ module SupportDesk
362
375
  # Automation (`by: :system`) is refused by name rather than by
363
376
  # NoMethodError three frames in — it is deferred work, not a typo (see
364
377
  # docs/12-open-questions.md Q17).
365
- def ensure_opener!(opener)
378
+ def ensure_opener!(opener, desk)
366
379
  if opener.is_a?(Symbol)
367
380
  raise NotAnAgent, "can't open a ticket as #{opener.inspect} — a case is opened by somebody who can " \
368
381
  "answer it, and automation openers aren't supported yet; pass an agent record"
@@ -371,6 +384,37 @@ module SupportDesk
371
384
  raise NotAnAgent, "can't open a ticket as an unsaved #{opener.class} — save the agent first"
372
385
  end
373
386
  ensure_agent_record!(opener)
387
+ return unless SupportDesk.ai_actor?(opener)
388
+
389
+ # Writing to somebody who never wrote to you is the one thing an
390
+ # assistant does that nobody asked for, so it takes its own
391
+ # permission on top of everything else.
392
+ unless opener.is_a?(SupportDesk::Assistant) && same_actor?(opener, desk.assistant)
393
+ raise NotAnAssistant,
394
+ "only desk #{desk.key}'s own assistant can open a case, and only when she may"
395
+ end
396
+ unless opener.may_open_conversations?
397
+ raise AssistantNotAllowed.new(nil, verb: :open,
398
+ message: "#{opener.key} may not open conversations — set " \
399
+ "`may_open_conversations = true` if that is the intent")
400
+ end
401
+ return if AssistantPolicy::RANK.fetch(opener.autonomy) >= AssistantPolicy::RANK.fetch(:reply)
402
+
403
+ raise AssistantNotAllowed.new(nil, verb: :open,
404
+ message: "#{opener.key} works at #{opener.autonomy}: opening a " \
405
+ "case means speaking first, which takes :reply")
406
+ end
407
+
408
+ # The topic's cap and the host's `cap` block, asked of the case this is
409
+ # ABOUT to be. Her per-case budget is not checked here: a brand new
410
+ # case has spent none of it, so the only way it could refuse is
411
+ # `max_turns 0`, and the policy below covers her level anyway.
412
+ def ensure_assistant_may_open!(opener, requester, desk, node)
413
+ candidate = new(desk: desk, requester: requester, topic: node)
414
+ policy = AssistantPolicy.for(candidate, opener)
415
+ return if policy.may_reply?
416
+
417
+ raise AssistantNotAllowed.new(policy, verb: :open)
374
418
  end
375
419
 
376
420
  def describe_record(record)
@@ -560,9 +604,12 @@ module SupportDesk
560
604
  # subscriber then finds the message already registered and does
561
605
  # nothing.
562
606
  def post_opening!(ticket, message, files:, by:, by_support:)
563
- notice = post_opening_line!(ticket)
607
+ notices = [ post_opening_line!(ticket) ]
564
608
 
565
- posted = if by_support
609
+ posted = if by_support && SupportDesk.ai_actor?(by)
610
+ notices << ticket.send(:post_disclosure_notice!, by) if by.notice?
611
+ ticket.send(:post_assistant_opening!, message, files: files, assistant: by)
612
+ elsif by_support
566
613
  # Never the inbound "no message, hand the ticket back" shortcut: a
567
614
  # desk that writes first with nothing to say is a chats validation
568
615
  # error, and this whole transaction goes with it.
@@ -572,7 +619,8 @@ module SupportDesk
572
619
  end
573
620
 
574
621
  ticket.send(:record_registration!, posted) if posted
575
- pin_opening_line!(ticket, notice, posted)
622
+ ticket.send(:stamp_assistant_action!) if by_support && SupportDesk.ai_actor?(by)
623
+ pin_opening_lines!(ticket, notices, posted)
576
624
  ticket
577
625
  end
578
626
 
@@ -595,11 +643,16 @@ module SupportDesk
595
643
  # Only this new notice moves, never anybody's real message, and the
596
644
  # human message still owns the conversation's last-message pointer, so
597
645
  # nothing has to be recomputed afterwards.
598
- def pin_opening_line!(ticket, notice, posted)
599
- return if notice.nil?
646
+ def pin_opening_lines!(ticket, notices, posted)
647
+ notices = Array(notices).compact
648
+ return if notices.empty?
600
649
 
601
650
  anchor = posted&.created_at || ticket.opened_at
602
- notice.update_columns(created_at: anchor - ordering_tick)
651
+ # Backwards, one tick each, so the last line written sits closest to
652
+ # the message and the first one opens the thread.
653
+ notices.reverse.each_with_index do |notice, index|
654
+ notice.update_columns(created_at: anchor - (ordering_tick * (index + 1)))
655
+ end
603
656
  # When there IS a first message it owns the conversation's
604
657
  # last-message pointer and nothing needs repairing. When there isn't,
605
658
  # the notice is that pointer, and chats denormalised its timestamp
@@ -622,8 +675,10 @@ module SupportDesk
622
675
  # running before any of it.
623
676
  def reply_into!(ticket, message, files:, by:, by_support:, request:, authorize_reuse:)
624
677
  if by_support
678
+ # Only this private outreach path supplies the current turn internally.
679
+ # Public replies must present the token their harness observed.
625
680
  ticket.send(:reply_under_lock!, message, by: by, files: files, request: request,
626
- authorize: authorize_reuse)
681
+ authorize: authorize_reuse, outreach: true)
627
682
  else
628
683
  ticket.with_lock(requires_new: true) do
629
684
  authorize_reuse&.call(ticket)
@@ -803,21 +858,40 @@ module SupportDesk
803
858
  # by whoever answers first and a drop-in on somebody else's ticket is
804
859
  # recorded; under :take_over the drop-in takes it; under :assignee_only
805
860
  # it raises SupportDesk::NotAllowed. Returns the Chats::Message.
806
- def reply!(body = nil, by: nil, files: [], request: nil)
861
+ #
862
+ # `by:` the assistant needs `turn:` and her policy's permission — see
863
+ # SupportDesk::Ticket::Assistance. `metadata:` rides on the message
864
+ # (provenance for a draft a human sent; hosts nest their own under
865
+ # "host").
866
+ def reply!(body = nil, by: nil, files: [], request: nil, metadata: {}, turn: nil)
807
867
  actor = resolve_actor(by)
808
868
  ensure_agent!(actor)
869
+ reconcile_and_commit! if SupportDesk.ai_actor?(actor)
809
870
 
810
- reply_under_lock!(body, by: actor, files: files, request: request)
871
+ reply_under_lock!(body, by: actor, files: files, request: request, metadata: metadata, turn: turn)
811
872
  end
812
873
 
813
874
  # An internal note: in the timeline and the console, never in the
814
875
  # conversation, never mirrored to any channel. Returns the Event.
815
- def note!(body, by: nil, request: nil)
876
+ def note!(body, by: nil, request: nil, turn: nil)
816
877
  actor = resolve_actor(by)
817
878
  ensure_agent!(actor)
879
+ machine = SupportDesk.ai_actor?(actor)
818
880
  raise ArgumentError, "a note needs something to say" if body.blank?
819
881
 
820
- event = write_transition!(:note, actor: actor, request: request) { { "note" => body.to_s } }
882
+ assistant = nil
883
+ event = write_transition!(:note, actor: actor, request: request) do
884
+ if machine
885
+ # Under the lock, from the row: a kill switch that commits while
886
+ # this call waits for the case is a kill switch that stops it (R9).
887
+ assistant = resolve_assistant!(actor)
888
+ ensure_current_turn!(turn)
889
+ policy = assistant_policy(assistant)
890
+ raise AssistantNotAllowed.new(policy, verb: :note) unless policy.may_observe?
891
+ end
892
+ { "note" => body.to_s }
893
+ end
894
+ stamp_assistant_action! if assistant && event
821
895
  SupportDesk.emit_after_commit(:note_added, self, event) if event
822
896
  event
823
897
  end
@@ -825,26 +899,44 @@ module SupportDesk
825
899
  # Give the ticket to an agent. `assign!(to: lucia, by: lucia)` is
826
900
  # somebody taking it; `assign!(to: pedro, by: admin)` is somebody being
827
901
  # handed it. Repeating an assignment to the current holder does nothing.
828
- def assign!(to:, by: nil, reason: nil, note: nil, request: nil)
902
+ def assign!(to:, by: nil, reason: nil, note: nil, request: nil, turn: nil)
829
903
  actor = resolve_actor(by)
830
904
  ensure_agent!(actor)
831
905
  ensure_assignable!(to)
832
906
 
833
907
  reason ||= to == actor ? :taken : :assigned
908
+ # A PERSON giving the case back to the assistant is the one action that
909
+ # lifts the three human-side flags — and it says so out loud, in the
910
+ # payload, because nothing else may clear them (I9).
911
+ handing_back = SupportDesk.ai_actor?(to) && !SupportDesk.ai_actor?(actor)
834
912
  assignment = nil
835
913
 
836
914
  event = write_transition!(:assigned, actor: actor, request: request) do
837
915
  raise InvalidTransition, "can't assign a closed ticket — reopen it first" if closed?
916
+ # Every mutable question about a seat is asked HERE, under the row
917
+ # lock, from the reloaded row: who holds the case, what her policy
918
+ # says, which turn she is on. Asked before the lock (0.3.0) they
919
+ # were answers about a case that has since moved, and a stale
920
+ # instance could take a seat a person had already been given (R2).
921
+ ensure_assistant_may_assign!(actor, to, turn: turn)
922
+ ensure_assistant_may_hold!(to)
838
923
  next false if assigned_to?(to)
839
924
 
840
925
  assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: reason, note: note)
841
- update!(assignee: to)
842
- { "assignee" => SupportDesk.actor_key(to), "reason" => reason.to_s }
926
+ attributes = { assignee: to }
927
+ if handing_back
928
+ attributes.merge!(human_required_at: nil, human_required_reason: nil, assistant_paused_at: nil,
929
+ assistant_paused_reason: nil, assistant_cap: nil)
930
+ end
931
+ update!(attributes)
932
+ { "assignee" => SupportDesk.actor_key(to), "reason" => reason.to_s,
933
+ "handed_back" => (true if handing_back) }.compact
843
934
  end
844
935
  return self unless event
845
936
 
846
937
  announce_assignment!(to, first: assignments.count <= 1)
847
938
  SupportDesk.emit_after_commit(:ticket_assigned, self, assignment)
939
+ emit_assistant_turn if handing_back && awaiting_reply?
848
940
  self
849
941
  end
850
942
 
@@ -853,7 +945,13 @@ module SupportDesk
853
945
  def hand_off!(to:, note: nil, by: nil, request: nil)
854
946
  actor = resolve_actor(by)
855
947
  ensure_agent!(actor)
948
+ if SupportDesk.ai_actor?(actor)
949
+ # She does not choose who picks a case up. `escalate!` is her way
950
+ # out: it asks for a person rather than naming one.
951
+ raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :hand_off)
952
+ end
856
953
  ensure_assignable!(to)
954
+ handing_back = SupportDesk.ai_actor?(to)
857
955
  from = nil
858
956
  assignment = nil
859
957
  event = write_transition!(:handed_off, actor: actor, request: request) do
@@ -862,28 +960,48 @@ module SupportDesk
862
960
  "(#{assignee ? describe_actor(assignee) : "nobody"} does) — use assign! to override"
863
961
  end
864
962
  raise InvalidTransition, "can't hand off a closed ticket" if closed?
963
+ ensure_assistant_may_hold!(to)
865
964
  next false if assigned_to?(to)
866
965
 
867
966
  from = assignee
868
967
  assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: :handed_off, note: note,
869
968
  release_reason: :handed_off)
870
- update!(assignee: to)
871
- { "from" => SupportDesk.actor_key(from), "to" => SupportDesk.actor_key(to), "note" => note }
969
+ attributes = { assignee: to }
970
+ if handing_back
971
+ attributes.merge!(human_required_at: nil, human_required_reason: nil, assistant_paused_at: nil,
972
+ assistant_paused_reason: nil, assistant_cap: nil)
973
+ end
974
+ update!(attributes)
975
+ { "from" => SupportDesk.actor_key(from), "to" => SupportDesk.actor_key(to), "note" => note,
976
+ "handed_back" => (true if handing_back) }
872
977
  end
873
978
  return self unless event
874
979
 
875
980
  announce_assignment!(to, first: false)
876
981
  SupportDesk.emit_after_commit(:ticket_handed_off, self, assignment, from: from, note: note)
982
+ emit_assistant_turn if handing_back && awaiting_reply?
877
983
  self
878
984
  end
879
985
 
880
986
  # Put the ticket back in the unassigned pile.
881
- def release!(by: nil, reason: :released, request: nil)
987
+ def release!(by: nil, reason: :released, request: nil, turn: nil)
882
988
  actor = resolve_actor(by)
883
989
  ensure_agent!(actor)
884
990
  from = nil
885
991
  event = write_transition!(:released, actor: actor, request: request) do
886
992
  raise InvalidTransition, "can't release a closed ticket" if closed?
993
+ assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
994
+ if assistant
995
+ # Giving a seat back is an action like any other, so it holds the
996
+ # turn it read: a run that finished after the case moved on must
997
+ # not release the seat a newer one took (R2).
998
+ ensure_current_turn!(turn)
999
+ policy = assistant_policy(assistant)
1000
+ raise AssistantNotAllowed.new(policy, verb: :release) unless policy.may_observe?
1001
+ # Her own seat, and only hers: putting a PERSON's case back in the
1002
+ # pile is not something a machine gets to decide.
1003
+ raise AssistantNotAllowed.new(policy, verb: :release) unless assigned_to?(assistant)
1004
+ end
887
1005
  next false if unassigned?
888
1006
 
889
1007
  from = assignee
@@ -898,17 +1016,37 @@ module SupportDesk
898
1016
  end
899
1017
 
900
1018
  # Close the case. Closing a closed ticket is a no-op, not an error.
901
- def close!(by: nil, request: nil)
1019
+ def close!(by: nil, request: nil, turn: nil)
902
1020
  actor = resolve_actor(by)
903
1021
  ensure_agent!(actor)
1022
+ machine = SupportDesk.ai_actor?(actor)
1023
+ reconcile_and_commit! if machine
904
1024
 
905
1025
  event = write_transition!(:closed, actor: actor, request: request) do
1026
+ if machine
1027
+ # Resolved under the lock, so the kill switch is read after the
1028
+ # wait rather than before it (R9).
1029
+ assistant = resolve_assistant!(actor)
1030
+ lock_conversation!
1031
+ reconcile_unregistered_messages!
1032
+ ensure_current_turn!(turn)
1033
+ policy = assistant_policy(assistant)
1034
+ # Four conditions, and every one of them is somebody else's word:
1035
+ # her level allows it, she is the one holding the case, the
1036
+ # customer has the last word (so nothing is waiting for an answer)
1037
+ # and nobody has asked for a person.
1038
+ raise AssistantNotAllowed.new(policy, verb: :close) unless policy.may_close?
1039
+ raise AssistantNotAllowed.new(policy, verb: :close) unless assigned_to?(assistant)
1040
+ raise AssistantNotAllowed.new(policy, verb: :close) unless awaiting_requester?
1041
+ raise AssistantNotAllowed.new(policy, verb: :close) if human_required?
1042
+ end
906
1043
  next false if closed?
907
1044
 
1045
+ expired = expire_pending_drafts!
908
1046
  assignments.open.each { |assignment| assignment.release!(reason: :closed) }
909
1047
  update!(status: "closed", closed_at: Time.current, closed_by: record_actor(actor),
910
1048
  awaiting: "none", waiting_since: nil)
911
- {}
1049
+ { "expired_drafts" => (expired if expired.positive?) }
912
1050
  end
913
1051
  return self unless event
914
1052
 
@@ -924,10 +1062,17 @@ module SupportDesk
924
1062
  # the pool.
925
1063
  def reopen!(by: nil, request: nil)
926
1064
  actor = resolve_actor(by)
1065
+ if SupportDesk.ai_actor?(actor)
1066
+ raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :reopen)
1067
+ end
927
1068
 
928
1069
  event = write_transition!(:reopened, actor: actor, request: request) do
929
1070
  next false unless closed?
930
1071
 
1072
+ # Read BEFORE the update clears it: whether the case we are bringing
1073
+ # back is one the assistant closed is the whole question behind the
1074
+ # cap below, and `closed_by` is where it is written down.
1075
+ closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
931
1076
  # Order matters: waiting_since is DERIVED from awaiting, so awaiting
932
1077
  # has to be the reopened value before it is read. Computing both in
933
1078
  # one update! hash reads the closed ticket's "none" and stores nil —
@@ -937,8 +1082,8 @@ module SupportDesk
937
1082
  cardinality_key: "reopened:#{id}")
938
1083
  self.waiting_since = waiting_since_from_clocks
939
1084
  save!
940
- restore_assignment!(by: actor)
941
- { "reopen_count" => reopen_count }
1085
+ capped = restore_assignment!(by: actor, closed_by_assistant: closed_by_assistant)
1086
+ { "reopen_count" => reopen_count, "assistant_capped" => (true if capped) }.compact
942
1087
  end
943
1088
  return self unless event
944
1089
 
@@ -953,6 +1098,12 @@ module SupportDesk
953
1098
  # Agents may file onto any topic in the tree, including ones no
954
1099
  # requester is offered (`only:`); requesters may not file at all.
955
1100
  ensure_agent!(actor)
1101
+ # Triage is where authority comes from — a topic decides the cap she
1102
+ # works under, so refiling her own case would be widening her own
1103
+ # policy (I10). 0.3 refuses it outright.
1104
+ if SupportDesk.ai_actor?(actor)
1105
+ raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :triage)
1106
+ end
956
1107
  node = desk_config.topics.find(to.to_s) ||
957
1108
  raise(UnknownTopic, "no topic #{to.inspect} on desk #{desk.key}")
958
1109
 
@@ -962,7 +1113,11 @@ module SupportDesk
962
1113
 
963
1114
  update!(topic: node, priority: [ priority.to_i, node.priority ].max,
964
1115
  cardinality_key: recomputed_cardinality_key(subject: subject, topic: node))
965
- { "from" => from&.path, "to" => node.path }
1116
+ # A refile onto a capped topic is a refile onto a case she may no
1117
+ # longer answer, so her seat goes with it — in the same transition,
1118
+ # so the queue never shows a machine holding a case it can't work.
1119
+ released = release_assistant_if_unfit!
1120
+ { "from" => from&.path, "to" => node.path, "assistant_released" => (true if released) }
966
1121
  end
967
1122
  return self unless event
968
1123
 
@@ -974,6 +1129,11 @@ module SupportDesk
974
1129
  def attach_subject!(record, by: nil, request: nil)
975
1130
  actor = resolve_actor(by)
976
1131
  ensure_agent!(actor)
1132
+ # Same reason as `change_topic!`: what a case is ABOUT decides what may
1133
+ # be done on it, so a machine does not get to say.
1134
+ if SupportDesk.ai_actor?(actor)
1135
+ raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :triage)
1136
+ end
977
1137
  unless record.respond_to?(:supportable?) && record.supportable?
978
1138
  raise NotSupportable, "#{record.class} isn't supportable — add `supportable topic: :something` to it"
979
1139
  end
@@ -1052,6 +1212,9 @@ module SupportDesk
1052
1212
  # test instead of quietly moving the boundary.
1053
1213
  def actions_for(agent)
1054
1214
  return [] unless agent.respond_to?(:support_agent?) && agent.support_agent?
1215
+ # A machine's verbs are its policy's, filtered by the same state. Same
1216
+ # boundary, different vocabulary — see #assistant_actions_for.
1217
+ return assistant_actions_for(agent) if SupportDesk.ai_actor?(agent)
1055
1218
  # Off duty is a real answer: the console can still show the case, and
1056
1219
  # an agent passing by can still leave a note, but nothing that speaks
1057
1220
  # to the requester is offered to somebody who isn't working.
@@ -1070,6 +1233,11 @@ module SupportDesk
1070
1233
  actions << :release if assigned?
1071
1234
  actions << :change_topic
1072
1235
  actions << :close
1236
+ if pending_draft
1237
+ actions << :send_draft if may_reply?(agent) && !requester_unavailable?
1238
+ actions << :reject_draft
1239
+ end
1240
+ actions << (assistant_paused? ? :resume_assistant : :pause_assistant) if assistant
1073
1241
  end
1074
1242
  actions
1075
1243
  end
@@ -1077,6 +1245,10 @@ module SupportDesk
1077
1245
  # Whether +agent+ may answer right now under this desk's reply policy.
1078
1246
  def may_reply?(agent)
1079
1247
  return false unless agent.respond_to?(:support_agent?) && agent.support_agent?
1248
+ # A case a machine is holding is a case any person may answer,
1249
+ # whatever the desk says about assignees: `:assignee_only` exists so
1250
+ # two people don't answer at once, and she is not one (I8).
1251
+ return true if held_by_assistant? && !SupportDesk.ai_actor?(agent)
1080
1252
  return true unless desk_config.reply_policy == :assignee_only
1081
1253
 
1082
1254
  assigned_to?(agent)
@@ -1086,9 +1258,11 @@ module SupportDesk
1086
1258
  # the whole on-duty pool while it's unheld. The gem computes it; the
1087
1259
  # host delivers it.
1088
1260
  def agents_to_notify
1089
- return [ assignee ].compact if assigned?
1261
+ # A case the assistant holds is a case no person has seen, so the pool
1262
+ # hears about it: notifying a machine is notifying nobody.
1263
+ return [ assignee ].compact if assigned? && !held_by_assistant?
1090
1264
 
1091
- desk.on_duty_agents.to_a
1265
+ desk.on_duty_agents.to_a.reject { |agent| SupportDesk.ai_actor?(agent) }
1092
1266
  end
1093
1267
 
1094
1268
  # Generic on purpose: a lock screen shouldn't spell out what somebody's
@@ -1132,8 +1306,15 @@ module SupportDesk
1132
1306
  # Post a message as the DESK, signed by the agent who wrote it — every
1133
1307
  # answer, and the desk's first word when it writes first. One place knows
1134
1308
  # "the desk sends, the human signs".
1135
- def post_agent_message!(body, files: [], by:) # :nodoc:
1136
- desk.message!(conversation, body, files: files, author: by)
1309
+ # `metadata:` is the provenance a message carries: who drafted it, what
1310
+ # policy allowed it, what the machine declared about it. Posted through
1311
+ # `messages.create!` rather than `desk.message!` for one reason only —
1312
+ # chats' sugar takes no metadata, and the validations are the same.
1313
+ def post_agent_message!(body, files: [], by:, metadata: {}) # :nodoc:
1314
+ attributes = { sender: desk, body: body, author: by }
1315
+ attributes[:files] = files if files.present?
1316
+ attributes[:metadata] = metadata if metadata.present?
1317
+ conversation.messages.create!(**attributes)
1137
1318
  end
1138
1319
 
1139
1320
  def inspect
@@ -1172,8 +1353,50 @@ module SupportDesk
1172
1353
  ensure_agent!(agent)
1173
1354
  end
1174
1355
 
1356
+ # "Could she hold this case once the human-side flags were lifted?" —
1357
+ # the hand-back question, so the very flags a hand-back exists to clear
1358
+ # are not what refuses it. Her autonomy, the topic and the host's cap
1359
+ # block still decide.
1360
+ #
1361
+ # Asked UNDER THE LOCK (R2): a cap that lands while a take waits for the
1362
+ # row is the cap that applies to it.
1363
+ def ensure_assistant_may_hold!(agent)
1364
+ return unless SupportDesk.ai_actor?(agent)
1365
+
1366
+ policy = assistant_policy(agent, hand_back: true)
1367
+ raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
1368
+ end
1369
+
1370
+ # An assistant may take a case, and that is all: only herself, only when
1371
+ # nobody holds it, only at a level that may answer, and only holding the
1372
+ # turn she read. Called under the row lock, from the reloaded row.
1373
+ def ensure_assistant_may_assign!(actor, to, turn:)
1374
+ return unless SupportDesk.ai_actor?(actor)
1375
+
1376
+ assistant = resolve_assistant!(actor)
1377
+ ensure_current_turn!(turn)
1378
+ policy = assistant_policy(assistant)
1379
+ unless self.class.same_actor?(actor, to)
1380
+ raise AssistantNotAllowed.new(policy, verb: :assign,
1381
+ message: "#{assistant.key} may not give #{reference} to " \
1382
+ "anybody — an assistant can only take a case herself")
1383
+ end
1384
+ raise AssistantNotAllowed.new(policy, verb: :take) unless unassigned?
1385
+ raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
1386
+ end
1387
+
1175
1388
  def ensure_agent!(actor)
1176
1389
  return if actor.is_a?(Symbol)
1390
+ # An AI-kind actor that is not a SupportDesk::Assistant is refused on
1391
+ # every write, by it or to it (I2). A host model declared `kind: :ai`
1392
+ # is a machine the gem knows nothing about, and treating it as a human
1393
+ # agent — which is what 0.2 did — hands it every human's authority.
1394
+ if SupportDesk.ai_actor?(actor) && !actor.is_a?(SupportDesk::Assistant)
1395
+ raise NotAnAssistant,
1396
+ "#{describe_actor(actor)} is declared `acts_as_support_agent kind: :ai` but isn't this gem's " \
1397
+ "assistant — configure one with `config.assistant` and act as SupportDesk.assistant(key)"
1398
+ end
1399
+
1177
1400
  self.class.ensure_agent_record!(actor)
1178
1401
  end
1179
1402
 
@@ -1211,17 +1434,52 @@ module SupportDesk
1211
1434
  # `authorize:` is the console's hook (see Ticket.open_or_reply!): it runs
1212
1435
  # under this lock, before any policy side effect, and a raise there rolls
1213
1436
  # the whole thing back.
1214
- def reply_under_lock!(body, by:, files:, request:, authorize: nil)
1437
+ def reply_under_lock!(body, by:, files:, request:, authorize: nil, metadata: {}, turn: nil, outreach: false)
1215
1438
  with_lock(requires_new: true) do
1216
1439
  authorize&.call(self)
1440
+ next assistant_reply_under_lock!(body, assistant: by, files: files, request: request,
1441
+ metadata: metadata, turn: turn, outreach: outreach) if SupportDesk.ai_actor?(by)
1442
+
1443
+ # Fold committed inputs before a human answer too. A delayed callback
1444
+ # must not later turn an already-answered question back into work.
1445
+ lock_conversation!
1446
+ reconcile_unregistered_messages!
1217
1447
  ensure_writable!
1218
1448
  apply_reply_policy!(by, request: request)
1219
- posted = post_agent_message!(body, files: files, by: by)
1449
+ posted = post_agent_message!(body, files: files, by: by, metadata: metadata)
1450
+ # A person answering makes every machine proposal on this case out of
1451
+ # date — including one they are about to send, which is why the draft
1452
+ # being sent says so and is left alone.
1453
+ supersede_pending_drafts!(except: metadata.dig("support_desk", "draft_id"))
1220
1454
  record_registration!(posted)
1221
1455
  posted
1222
1456
  end
1223
1457
  end
1224
1458
 
1459
+ # `reply!` by the assistant: her full rules, under the lock the caller
1460
+ # already holds. Everything here is also what `respond!` runs — this is
1461
+ # the path for a host that decided to answer rather than ask policy.
1462
+ def assistant_reply_under_lock!(body, assistant:, files:, request:, metadata:, turn:, outreach: false)
1463
+ assistant = resolve_assistant!(assistant)
1464
+ # Private outreach supplies a token only after acquiring both locks;
1465
+ # public replies compare the caller's actual observed token.
1466
+ lock_conversation!
1467
+ reconcile_unregistered_messages!
1468
+ turn = assistant_turn if outreach
1469
+ ensure_current_turn!(turn)
1470
+ ensure_writable!
1471
+
1472
+ policy = assistant_policy(assistant)
1473
+ raise AssistantNotAllowed.new(policy, verb: :reply) unless policy.may_reply?
1474
+ raise AssistantNotAllowed.new(policy, verb: :not_your_turn) unless awaiting_reply?
1475
+
1476
+ left = assistant_turns_left
1477
+ raise AssistantNotAllowed.new(policy, verb: :max_turns) if left&.zero?
1478
+
1479
+ speak!(body, assistant, policy: policy, turn: turn, files: files, confidence: nil, sources: [],
1480
+ metadata: metadata, request: request)
1481
+ end
1482
+
1225
1483
  def apply_reply_policy!(actor, request: nil)
1226
1484
  # A closed case has no seat to take: an agent adding one last word
1227
1485
  # posts it and the case stays closed. (A REQUESTER writing is what
@@ -1236,14 +1494,27 @@ module SupportDesk
1236
1494
  "take it first"
1237
1495
  end
1238
1496
 
1239
- assign!(to: actor, by: actor, request: request)
1497
+ # This private path already validated the reply while holding the lock.
1498
+ # Seat-taking consumes the real current token, not a public sentinel.
1499
+ assign!(to: actor, by: actor, request: request, turn: assistant_turn)
1240
1500
  elsif !assigned_to?(actor)
1501
+ # Humans outrank assistants, under EVERY reply policy (I8). A person
1502
+ # answering a case a machine is holding takes it over: there is
1503
+ # nothing to ask about "who owns this" when one of the two can't
1504
+ # want it, and leaving her seated would keep her answering next.
1505
+ if held_by_assistant? && !SupportDesk.ai_actor?(actor)
1506
+ return assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request, turn: assistant_turn)
1507
+ end
1508
+ # Belt: the `held_by_human` floor already turned this into a draft,
1509
+ # so an assistant reaching here is a bug, not a policy question.
1510
+ raise AssistantNotAllowed.new(assistant_policy(actor), verb: :reply) if SupportDesk.ai_actor?(actor)
1511
+
1241
1512
  case policy
1242
1513
  when :assignee_only
1243
1514
  raise NotAllowed, "ticket #{reference} is held by #{describe_actor(assignee)} and this desk only " \
1244
1515
  "lets the assignee reply"
1245
1516
  when :take_over
1246
- assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
1517
+ assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request, turn: assistant_turn)
1247
1518
  else
1248
1519
  write_transition!(:drop_in, actor: actor, request: request) do
1249
1520
  { "assignee" => SupportDesk.actor_key(assignee) }
@@ -1270,7 +1541,11 @@ module SupportDesk
1270
1541
  payload = yield
1271
1542
  return nil if payload == false || payload.nil?
1272
1543
 
1273
- Event.record!(ticket: self, kind: kind, actor: actor, payload: payload.compact)
1544
+ event = Event.record!(ticket: self, kind: kind, actor: actor, payload: payload.compact)
1545
+ # A real transition changed the case, so it moves the turn — a
1546
+ # no-op one wrote no event and moves nothing.
1547
+ bump_assistant_revision!
1548
+ event
1274
1549
  end
1275
1550
 
1276
1551
  # The half that tells the world, once the write is durable. Every
@@ -1288,6 +1563,10 @@ module SupportDesk
1288
1563
  end
1289
1564
 
1290
1565
  def announce_assignment!(agent, first:)
1566
+ # "Rose se ocupa de tu consulta" is a promise about a person. Her
1567
+ # disclosure line is her announcement, and it is the only one.
1568
+ return if SupportDesk.ai_actor?(agent)
1569
+
1291
1570
  mode = desk_config.announce_assignments
1292
1571
  return if mode == :never
1293
1572
  return if mode == :first_only && !first
@@ -1310,27 +1589,37 @@ module SupportDesk
1310
1589
  # caller is not always the lock holder it thinks it is.
1311
1590
  def record_registration!(message) # :nodoc:
1312
1591
  return self if role_of(message) == :system
1592
+ unless message.persisted? && message.conversation_id == conversation_id
1593
+ raise ArgumentError, "message must belong to this ticket's conversation"
1594
+ end
1313
1595
  # Re-check under the lock: the same message can reach us twice (a
1314
1596
  # redelivered event, a hand-written replay), and an SLA clock that
1315
1597
  # moves twice for one message is a lie.
1316
1598
  return self if registered?(message)
1317
1599
 
1600
+ message_registrations.create!(message_id: message.id)
1318
1601
  role = role_of(message)
1319
1602
  opening = opening_message?
1320
1603
  reopened = false
1321
1604
  reopen_event = nil
1322
1605
 
1323
1606
  attributes = { last_registered_message_id: message.id }
1607
+ closed_by_assistant = false
1324
1608
  case role
1325
1609
  when :requester
1326
- attributes[:last_requester_message_at] = message.created_at
1610
+ attributes[:last_requester_message_at] = [ last_requester_message_at, message.created_at ].compact.max
1611
+ # The pointer the turn's reconciliation reads: which requester
1612
+ # message the clocks are standing on, by id and not only by time.
1613
+ attributes[:last_requester_message_id] = message.id if after_requester_watermark?(message)
1327
1614
  if closed? && message.created_at > closed_at && desk_config.closed_tickets == :reopen_on_reply
1615
+ # Read before the merge below clears it — see #reopen!.
1616
+ closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
1328
1617
  attributes.merge!(status: "open", closed_at: nil, closed_by: nil,
1329
1618
  reopen_count: reopen_count.to_i + 1, cardinality_key: "reopened:#{id}")
1330
1619
  reopened = true
1331
1620
  end
1332
1621
  when :agent
1333
- attributes[:last_agent_message_at] = message.created_at
1622
+ attributes[:last_agent_message_at] = [ last_agent_message_at, message.created_at ].compact.max
1334
1623
  # A first REPLY answers something. An agent message with no earlier
1335
1624
  # requester message is the desk opening the conversation, or a word
1336
1625
  # into an empty case — neither is a reply, and a requester clock that
@@ -1345,41 +1634,74 @@ module SupportDesk
1345
1634
  end
1346
1635
 
1347
1636
  assign_attributes(attributes)
1348
- self.awaiting = closed? ? "none" : awaiting_from_clocks
1637
+ self.awaiting = if closed?
1638
+ "none"
1639
+ elsif role == :requester
1640
+ "agent"
1641
+ else
1642
+ awaiting_from_clocks
1643
+ end
1349
1644
  self.waiting_since = waiting_since_from_clocks
1350
1645
  save!
1646
+ # Every registered message moves the case on, so every one of them
1647
+ # moves the turn: an assistant holding the turn she read a second ago
1648
+ # is holding a case that has not changed since (I4).
1649
+ bump_assistant_revision!
1351
1650
 
1352
1651
  if reopened
1353
- restore_assignment!(by: :system)
1354
- reopen_event = record_transition!(:reopened, actor: requester) { { "via" => "requester_reply" } }
1652
+ capped = restore_assignment!(by: :system, closed_by_assistant: closed_by_assistant)
1653
+ reopen_event = record_transition!(:reopened, actor: requester) do
1654
+ { "via" => "requester_reply", "assistant_capped" => (true if capped) }.compact
1655
+ end
1355
1656
  end
1356
1657
 
1658
+ # After the bump, and after any reopen: the hook decides about a case
1659
+ # in the state this message left it in.
1660
+ evaluate_hand_off_phrase!(message) if role == :requester && !closed?
1661
+
1357
1662
  publish_transition(reopen_event, :reopened, requester, nil) if reopen_event
1358
1663
  announce_registration(message, role: role, opening: opening, reopened: reopened)
1359
1664
  self
1360
1665
  end
1361
1666
 
1362
- # Whether this message is already folded in. The last-id check catches
1363
- # the common redelivery; the clock check catches the rest, because a
1364
- # REPLAY can arrive in any order and an older message must never rewind
1365
- # `awaiting`, restart an SLA clock, or reopen a case that was closed
1366
- # after it.
1367
- #
1368
- # The comparison is `<=`, so a message whose timestamp already sits on
1369
- # the clock counts as folded in: idempotency is the documented promise,
1370
- # and the cost of the rare tie is one clock that doesn't advance until
1371
- # the next message.
1667
+ # Receipt identity, not transcript order, decides whether a callback is a
1668
+ # replay. A later commit may have an earlier timestamp or a smaller UUID.
1372
1669
  def registered?(message)
1373
- return true if last_registered_message_id.present? && last_registered_message_id.to_s == message.id.to_s
1670
+ message_registrations.exists?(message_id: message.id)
1671
+ end
1374
1672
 
1375
- clock = case role_of(message)
1376
- when :requester then last_requester_message_at
1377
- when :agent then last_agent_message_at
1378
- end
1673
+ # Advance the requester clock pointer only in transcript order. Receipt
1674
+ # identity decides registration separately, so late arrivals cannot rewind
1675
+ # clocks or be discarded as replays.
1676
+ def after_requester_watermark?(message)
1677
+ watermark = last_requester_message_at
1678
+ return true if watermark.blank?
1679
+ return true if message.created_at > watermark
1680
+ return false if message.created_at < watermark
1681
+ # A tie with no pointer to break it (a 0.2 row whose backfill found
1682
+ # nothing) is new: the alternative silently drops it.
1683
+ return true if last_requester_message_id.blank?
1684
+
1685
+ compare_message_ids(message.id, last_requester_message_id).positive?
1686
+ end
1379
1687
 
1380
- clock.present? && message.created_at <= clock
1688
+ # Order two chats message ids the way the DATABASE orders that column,
1689
+ # because the reconciliation query compares them in SQL and this one
1690
+ # compares them in Ruby. Integers compare numerically (10 is after 9);
1691
+ # anything else — a uuid, a ULID — compares as a string, which is how
1692
+ # every adapter orders those columns too.
1693
+ def compare_message_ids(one, other)
1694
+ if one.is_a?(Integer) && other.is_a?(Integer)
1695
+ one <=> other
1696
+ elsif integerish?(one) && integerish?(other)
1697
+ one.to_i <=> other.to_i
1698
+ else
1699
+ one.to_s <=> other.to_s
1700
+ end
1381
1701
  end
1382
1702
 
1703
+ def integerish?(value) = value.to_s.match?(/\A-?\d+\z/)
1704
+
1383
1705
  def opening_message?
1384
1706
  last_registered_message_id.blank? && last_requester_message_at.nil? && last_agent_message_at.nil?
1385
1707
  end
@@ -1432,14 +1754,26 @@ module SupportDesk
1432
1754
  # disagree on a live ticket. A closed ticket keeps its assignee as the
1433
1755
  # record of who dealt with it, with no open row — that pair is the one
1434
1756
  # shape `doctor` expects to see.
1435
- def restore_assignment!(by:)
1436
- return if unassigned?
1757
+ def restore_assignment!(by:, closed_by_assistant: false)
1758
+ return false if unassigned?
1759
+
1760
+ # A case the assistant closed and the customer reopened is a case she
1761
+ # got wrong (12 #21). It comes back to PEOPLE — unassigned, and capped
1762
+ # at :draft on this case for good, whatever her level is elsewhere. An
1763
+ # explicit hand-back is the only thing that lifts it, and the cap can
1764
+ # only tighten: an existing :observe stays :observe.
1765
+ if closed_by_assistant
1766
+ cap = [ assistant_cap&.to_sym, :draft ].compact.min_by { |level| AssistantPolicy::RANK.fetch(level) }
1767
+ update!(assignee: nil, assistant_cap: cap.to_s)
1768
+ return true
1769
+ end
1437
1770
 
1438
1771
  if assignee.respond_to?(:support_agent?) && assignee.support_agent?
1439
1772
  Assignment.open!(ticket: self, agent: assignee, by: by, reason: :reopened)
1440
1773
  else
1441
1774
  update!(assignee: nil)
1442
1775
  end
1776
+ false
1443
1777
  end
1444
1778
 
1445
1779
  def awaiting_from_clocks
@@ -1464,6 +1798,10 @@ module SupportDesk
1464
1798
 
1465
1799
  case role
1466
1800
  when :requester
1801
+ # The opening message included: a case that starts with a question
1802
+ # is a case with something to answer, and the harness should hear
1803
+ # about it the same way it hears about every later message.
1804
+ emit_assistant_turn(message)
1467
1805
  SupportDesk.emit_after_commit(:requester_replied, self, message) unless opening
1468
1806
  when :agent
1469
1807
  # The desk's OWN first word announces nothing: it is the start of a
@@ -1497,11 +1835,21 @@ module SupportDesk
1497
1835
  conversation.messages.visible.oldest_first.map do |message|
1498
1836
  {
1499
1837
  at: message.created_at,
1500
- from: role_of(message) == :requester ? "you" : "support",
1838
+ # "assistant" in every disclosure mode, silent ones included: what
1839
+ # a customer was told is a product decision, what an export says a
1840
+ # machine wrote is not.
1841
+ from: export_from(message),
1501
1842
  body: message.visible_body,
1502
1843
  attachments: message.try(:files)&.map { |file| file.try(:filename).to_s } || []
1503
1844
  }
1504
1845
  end
1505
1846
  end
1847
+
1848
+ def export_from(message)
1849
+ return "you" if role_of(message) == :requester
1850
+ return "assistant" if assistant_message?(message)
1851
+
1852
+ "support"
1853
+ end
1506
1854
  end
1507
1855
  end