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
@@ -61,6 +61,12 @@ module SupportDesk
61
61
  has_many :events, class_name: "SupportDesk::Event", inverse_of: :ticket, dependent: :delete_all
62
62
  has_many :messages, through: :conversation, source: :messages
63
63
 
64
+ # The assistant surface — the turn, the policy gates, her two verbs and
65
+ # the two exits — in its own file. The GATES inside the verbs below stay
66
+ # here, next to the verbs they guard: a reader looking at `close!` has to
67
+ # see what stops a machine closing a case.
68
+ include Assistance
69
+
64
70
  # Persist only the path. Resolving behavior needs this ticket's desk;
65
71
  # an attribute caster has no record context and cannot choose the tree.
66
72
  attribute :topic, Topic::Type.new
@@ -224,12 +230,14 @@ module SupportDesk
224
230
  # something to infer from Current.actor, and an agent passed as their
225
231
  # own requester is asking for help, not writing to themselves.
226
232
  by_support = !same_actor?(opener, requester)
227
- ensure_opener!(opener) if by_support
233
+ ensure_opener!(opener, desk) if by_support
228
234
 
229
235
  # The subject is checked BEFORE the topic: "this isn't supportable" is
230
236
  # the useful error, and an unsupportable record has no topic to find.
231
237
  validate_subject!(about, requester)
232
238
  node = resolve_topic!(topic, about, desk, requester, about, by_support: by_support)
239
+ # The topic is what caps her, so the cap is checked once it is known.
240
+ ensure_assistant_may_open!(opener, requester, desk, node) if by_support && SupportDesk.ai_actor?(opener)
233
241
 
234
242
  cardinality = cardinality_key_for(requester: requester, subject: about, topic: node)
235
243
  existing = existing_for(requester: requester, desk: desk, subject: about, topic: node)
@@ -362,7 +370,7 @@ module SupportDesk
362
370
  # Automation (`by: :system`) is refused by name rather than by
363
371
  # NoMethodError three frames in — it is deferred work, not a typo (see
364
372
  # docs/12-open-questions.md Q17).
365
- def ensure_opener!(opener)
373
+ def ensure_opener!(opener, desk)
366
374
  if opener.is_a?(Symbol)
367
375
  raise NotAnAgent, "can't open a ticket as #{opener.inspect} — a case is opened by somebody who can " \
368
376
  "answer it, and automation openers aren't supported yet; pass an agent record"
@@ -371,6 +379,37 @@ module SupportDesk
371
379
  raise NotAnAgent, "can't open a ticket as an unsaved #{opener.class} — save the agent first"
372
380
  end
373
381
  ensure_agent_record!(opener)
382
+ return unless SupportDesk.ai_actor?(opener)
383
+
384
+ # Writing to somebody who never wrote to you is the one thing an
385
+ # assistant does that nobody asked for, so it takes its own
386
+ # permission on top of everything else.
387
+ unless opener.is_a?(SupportDesk::Assistant) && same_actor?(opener, desk.assistant)
388
+ raise NotAnAssistant,
389
+ "only desk #{desk.key}'s own assistant can open a case, and only when she may"
390
+ end
391
+ unless opener.may_open_conversations?
392
+ raise AssistantNotAllowed.new(nil, verb: :open,
393
+ message: "#{opener.key} may not open conversations — set " \
394
+ "`may_open_conversations = true` if that is the intent")
395
+ end
396
+ return if AssistantPolicy::RANK.fetch(opener.autonomy) >= AssistantPolicy::RANK.fetch(:reply)
397
+
398
+ raise AssistantNotAllowed.new(nil, verb: :open,
399
+ message: "#{opener.key} works at #{opener.autonomy}: opening a " \
400
+ "case means speaking first, which takes :reply")
401
+ end
402
+
403
+ # The topic's cap and the host's `cap` block, asked of the case this is
404
+ # ABOUT to be. Her per-case budget is not checked here: a brand new
405
+ # case has spent none of it, so the only way it could refuse is
406
+ # `max_turns 0`, and the policy below covers her level anyway.
407
+ def ensure_assistant_may_open!(opener, requester, desk, node)
408
+ candidate = new(desk: desk, requester: requester, topic: node)
409
+ policy = AssistantPolicy.for(candidate, opener)
410
+ return if policy.may_reply?
411
+
412
+ raise AssistantNotAllowed.new(policy, verb: :open)
374
413
  end
375
414
 
376
415
  def describe_record(record)
@@ -560,9 +599,12 @@ module SupportDesk
560
599
  # subscriber then finds the message already registered and does
561
600
  # nothing.
562
601
  def post_opening!(ticket, message, files:, by:, by_support:)
563
- notice = post_opening_line!(ticket)
602
+ notices = [ post_opening_line!(ticket) ]
564
603
 
565
- posted = if by_support
604
+ posted = if by_support && SupportDesk.ai_actor?(by)
605
+ notices << ticket.send(:post_disclosure_notice!, by) if by.notice?
606
+ ticket.send(:post_assistant_opening!, message, files: files, assistant: by)
607
+ elsif by_support
566
608
  # Never the inbound "no message, hand the ticket back" shortcut: a
567
609
  # desk that writes first with nothing to say is a chats validation
568
610
  # error, and this whole transaction goes with it.
@@ -572,7 +614,8 @@ module SupportDesk
572
614
  end
573
615
 
574
616
  ticket.send(:record_registration!, posted) if posted
575
- pin_opening_line!(ticket, notice, posted)
617
+ ticket.send(:stamp_assistant_action!) if by_support && SupportDesk.ai_actor?(by)
618
+ pin_opening_lines!(ticket, notices, posted)
576
619
  ticket
577
620
  end
578
621
 
@@ -595,11 +638,16 @@ module SupportDesk
595
638
  # Only this new notice moves, never anybody's real message, and the
596
639
  # human message still owns the conversation's last-message pointer, so
597
640
  # nothing has to be recomputed afterwards.
598
- def pin_opening_line!(ticket, notice, posted)
599
- return if notice.nil?
641
+ def pin_opening_lines!(ticket, notices, posted)
642
+ notices = Array(notices).compact
643
+ return if notices.empty?
600
644
 
601
645
  anchor = posted&.created_at || ticket.opened_at
602
- notice.update_columns(created_at: anchor - ordering_tick)
646
+ # Backwards, one tick each, so the last line written sits closest to
647
+ # the message and the first one opens the thread.
648
+ notices.reverse.each_with_index do |notice, index|
649
+ notice.update_columns(created_at: anchor - (ordering_tick * (index + 1)))
650
+ end
603
651
  # When there IS a first message it owns the conversation's
604
652
  # last-message pointer and nothing needs repairing. When there isn't,
605
653
  # the notice is that pointer, and chats denormalised its timestamp
@@ -622,8 +670,12 @@ module SupportDesk
622
670
  # running before any of it.
623
671
  def reply_into!(ticket, message, files:, by:, by_support:, request:, authorize_reuse:)
624
672
  if by_support
673
+ # `turn: :current` is for the assistant: reuse turns outreach into
674
+ # an ordinary reply, with her full rules — and the turn she has to
675
+ # hold is the one this lock reads, because there was never an
676
+ # earlier one to give her.
625
677
  ticket.send(:reply_under_lock!, message, by: by, files: files, request: request,
626
- authorize: authorize_reuse)
678
+ authorize: authorize_reuse, turn: :current)
627
679
  else
628
680
  ticket.with_lock(requires_new: true) do
629
681
  authorize_reuse&.call(ticket)
@@ -803,21 +855,35 @@ module SupportDesk
803
855
  # by whoever answers first and a drop-in on somebody else's ticket is
804
856
  # recorded; under :take_over the drop-in takes it; under :assignee_only
805
857
  # it raises SupportDesk::NotAllowed. Returns the Chats::Message.
806
- def reply!(body = nil, by: nil, files: [], request: nil)
858
+ #
859
+ # `by:` the assistant needs `turn:` and her policy's permission — see
860
+ # SupportDesk::Ticket::Assistance. `metadata:` rides on the message
861
+ # (provenance for a draft a human sent; hosts nest their own under
862
+ # "host").
863
+ def reply!(body = nil, by: nil, files: [], request: nil, metadata: {}, turn: nil)
807
864
  actor = resolve_actor(by)
808
865
  ensure_agent!(actor)
809
866
 
810
- reply_under_lock!(body, by: actor, files: files, request: request)
867
+ reply_under_lock!(body, by: actor, files: files, request: request, metadata: metadata, turn: turn)
811
868
  end
812
869
 
813
870
  # An internal note: in the timeline and the console, never in the
814
871
  # conversation, never mirrored to any channel. Returns the Event.
815
- def note!(body, by: nil, request: nil)
872
+ def note!(body, by: nil, request: nil, turn: nil)
816
873
  actor = resolve_actor(by)
817
874
  ensure_agent!(actor)
875
+ assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
818
876
  raise ArgumentError, "a note needs something to say" if body.blank?
819
877
 
820
- event = write_transition!(:note, actor: actor, request: request) { { "note" => body.to_s } }
878
+ event = write_transition!(:note, actor: actor, request: request) do
879
+ if assistant
880
+ ensure_current_turn!(turn)
881
+ policy = assistant_policy(assistant)
882
+ raise AssistantNotAllowed.new(policy, verb: :note) unless policy.may_observe?
883
+ end
884
+ { "note" => body.to_s }
885
+ end
886
+ stamp_assistant_action! if assistant && event
821
887
  SupportDesk.emit_after_commit(:note_added, self, event) if event
822
888
  event
823
889
  end
@@ -828,9 +894,14 @@ module SupportDesk
828
894
  def assign!(to:, by: nil, reason: nil, note: nil, request: nil)
829
895
  actor = resolve_actor(by)
830
896
  ensure_agent!(actor)
897
+ ensure_assistant_may_assign!(actor, to)
831
898
  ensure_assignable!(to)
832
899
 
833
900
  reason ||= to == actor ? :taken : :assigned
901
+ # A PERSON giving the case back to the assistant is the one action that
902
+ # lifts the three human-side flags — and it says so out loud, in the
903
+ # payload, because nothing else may clear them (I9).
904
+ handing_back = SupportDesk.ai_actor?(to) && !SupportDesk.ai_actor?(actor)
834
905
  assignment = nil
835
906
 
836
907
  event = write_transition!(:assigned, actor: actor, request: request) do
@@ -838,13 +909,20 @@ module SupportDesk
838
909
  next false if assigned_to?(to)
839
910
 
840
911
  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 }
912
+ attributes = { assignee: to }
913
+ if handing_back
914
+ attributes.merge!(human_required_at: nil, human_required_reason: nil, assistant_paused_at: nil,
915
+ assistant_paused_reason: nil, assistant_cap: nil)
916
+ end
917
+ update!(attributes)
918
+ { "assignee" => SupportDesk.actor_key(to), "reason" => reason.to_s,
919
+ "handed_back" => (true if handing_back) }.compact
843
920
  end
844
921
  return self unless event
845
922
 
846
923
  announce_assignment!(to, first: assignments.count <= 1)
847
924
  SupportDesk.emit_after_commit(:ticket_assigned, self, assignment)
925
+ emit_assistant_turn if handing_back && awaiting_reply?
848
926
  self
849
927
  end
850
928
 
@@ -853,7 +931,13 @@ module SupportDesk
853
931
  def hand_off!(to:, note: nil, by: nil, request: nil)
854
932
  actor = resolve_actor(by)
855
933
  ensure_agent!(actor)
934
+ if SupportDesk.ai_actor?(actor)
935
+ # She does not choose who picks a case up. `escalate!` is her way
936
+ # out: it asks for a person rather than naming one.
937
+ raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :hand_off)
938
+ end
856
939
  ensure_assignable!(to)
940
+ handing_back = SupportDesk.ai_actor?(to)
857
941
  from = nil
858
942
  assignment = nil
859
943
  event = write_transition!(:handed_off, actor: actor, request: request) do
@@ -867,13 +951,20 @@ module SupportDesk
867
951
  from = assignee
868
952
  assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: :handed_off, note: note,
869
953
  release_reason: :handed_off)
870
- update!(assignee: to)
871
- { "from" => SupportDesk.actor_key(from), "to" => SupportDesk.actor_key(to), "note" => note }
954
+ attributes = { assignee: to }
955
+ if handing_back
956
+ attributes.merge!(human_required_at: nil, human_required_reason: nil, assistant_paused_at: nil,
957
+ assistant_paused_reason: nil, assistant_cap: nil)
958
+ end
959
+ update!(attributes)
960
+ { "from" => SupportDesk.actor_key(from), "to" => SupportDesk.actor_key(to), "note" => note,
961
+ "handed_back" => (true if handing_back) }
872
962
  end
873
963
  return self unless event
874
964
 
875
965
  announce_assignment!(to, first: false)
876
966
  SupportDesk.emit_after_commit(:ticket_handed_off, self, assignment, from: from, note: note)
967
+ emit_assistant_turn if handing_back && awaiting_reply?
877
968
  self
878
969
  end
879
970
 
@@ -881,9 +972,17 @@ module SupportDesk
881
972
  def release!(by: nil, reason: :released, request: nil)
882
973
  actor = resolve_actor(by)
883
974
  ensure_agent!(actor)
975
+ assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
884
976
  from = nil
885
977
  event = write_transition!(:released, actor: actor, request: request) do
886
978
  raise InvalidTransition, "can't release a closed ticket" if closed?
979
+ if assistant
980
+ policy = assistant_policy(assistant)
981
+ raise AssistantNotAllowed.new(policy, verb: :release) unless policy.may_observe?
982
+ # Her own seat, and only hers: putting a PERSON's case back in the
983
+ # pile is not something a machine gets to decide.
984
+ raise AssistantNotAllowed.new(policy, verb: :release) unless assigned_to?(assistant)
985
+ end
887
986
  next false if unassigned?
888
987
 
889
988
  from = assignee
@@ -898,17 +997,32 @@ module SupportDesk
898
997
  end
899
998
 
900
999
  # Close the case. Closing a closed ticket is a no-op, not an error.
901
- def close!(by: nil, request: nil)
1000
+ def close!(by: nil, request: nil, turn: nil)
902
1001
  actor = resolve_actor(by)
903
1002
  ensure_agent!(actor)
1003
+ assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
904
1004
 
905
1005
  event = write_transition!(:closed, actor: actor, request: request) do
1006
+ if assistant
1007
+ reconcile_unregistered_messages!
1008
+ ensure_current_turn!(turn)
1009
+ policy = assistant_policy(assistant)
1010
+ # Four conditions, and every one of them is somebody else's word:
1011
+ # her level allows it, she is the one holding the case, the
1012
+ # customer has the last word (so nothing is waiting for an answer)
1013
+ # and nobody has asked for a person.
1014
+ raise AssistantNotAllowed.new(policy, verb: :close) unless policy.may_close?
1015
+ raise AssistantNotAllowed.new(policy, verb: :close) unless assigned_to?(assistant)
1016
+ raise AssistantNotAllowed.new(policy, verb: :close) unless awaiting_requester?
1017
+ raise AssistantNotAllowed.new(policy, verb: :close) if human_required?
1018
+ end
906
1019
  next false if closed?
907
1020
 
1021
+ expired = expire_pending_drafts!
908
1022
  assignments.open.each { |assignment| assignment.release!(reason: :closed) }
909
1023
  update!(status: "closed", closed_at: Time.current, closed_by: record_actor(actor),
910
1024
  awaiting: "none", waiting_since: nil)
911
- {}
1025
+ { "expired_drafts" => (expired if expired.positive?) }
912
1026
  end
913
1027
  return self unless event
914
1028
 
@@ -924,10 +1038,17 @@ module SupportDesk
924
1038
  # the pool.
925
1039
  def reopen!(by: nil, request: nil)
926
1040
  actor = resolve_actor(by)
1041
+ if SupportDesk.ai_actor?(actor)
1042
+ raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :reopen)
1043
+ end
927
1044
 
928
1045
  event = write_transition!(:reopened, actor: actor, request: request) do
929
1046
  next false unless closed?
930
1047
 
1048
+ # Read BEFORE the update clears it: whether the case we are bringing
1049
+ # back is one the assistant closed is the whole question behind the
1050
+ # cap below, and `closed_by` is where it is written down.
1051
+ closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
931
1052
  # Order matters: waiting_since is DERIVED from awaiting, so awaiting
932
1053
  # has to be the reopened value before it is read. Computing both in
933
1054
  # one update! hash reads the closed ticket's "none" and stores nil —
@@ -937,8 +1058,8 @@ module SupportDesk
937
1058
  cardinality_key: "reopened:#{id}")
938
1059
  self.waiting_since = waiting_since_from_clocks
939
1060
  save!
940
- restore_assignment!(by: actor)
941
- { "reopen_count" => reopen_count }
1061
+ capped = restore_assignment!(by: actor, closed_by_assistant: closed_by_assistant)
1062
+ { "reopen_count" => reopen_count, "assistant_capped" => (true if capped) }.compact
942
1063
  end
943
1064
  return self unless event
944
1065
 
@@ -953,6 +1074,12 @@ module SupportDesk
953
1074
  # Agents may file onto any topic in the tree, including ones no
954
1075
  # requester is offered (`only:`); requesters may not file at all.
955
1076
  ensure_agent!(actor)
1077
+ # Triage is where authority comes from — a topic decides the cap she
1078
+ # works under, so refiling her own case would be widening her own
1079
+ # policy (I10). 0.3 refuses it outright.
1080
+ if SupportDesk.ai_actor?(actor)
1081
+ raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :triage)
1082
+ end
956
1083
  node = desk_config.topics.find(to.to_s) ||
957
1084
  raise(UnknownTopic, "no topic #{to.inspect} on desk #{desk.key}")
958
1085
 
@@ -962,7 +1089,11 @@ module SupportDesk
962
1089
 
963
1090
  update!(topic: node, priority: [ priority.to_i, node.priority ].max,
964
1091
  cardinality_key: recomputed_cardinality_key(subject: subject, topic: node))
965
- { "from" => from&.path, "to" => node.path }
1092
+ # A refile onto a capped topic is a refile onto a case she may no
1093
+ # longer answer, so her seat goes with it — in the same transition,
1094
+ # so the queue never shows a machine holding a case it can't work.
1095
+ released = release_assistant_if_unfit!
1096
+ { "from" => from&.path, "to" => node.path, "assistant_released" => (true if released) }
966
1097
  end
967
1098
  return self unless event
968
1099
 
@@ -974,6 +1105,11 @@ module SupportDesk
974
1105
  def attach_subject!(record, by: nil, request: nil)
975
1106
  actor = resolve_actor(by)
976
1107
  ensure_agent!(actor)
1108
+ # Same reason as `change_topic!`: what a case is ABOUT decides what may
1109
+ # be done on it, so a machine does not get to say.
1110
+ if SupportDesk.ai_actor?(actor)
1111
+ raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :triage)
1112
+ end
977
1113
  unless record.respond_to?(:supportable?) && record.supportable?
978
1114
  raise NotSupportable, "#{record.class} isn't supportable — add `supportable topic: :something` to it"
979
1115
  end
@@ -1052,6 +1188,9 @@ module SupportDesk
1052
1188
  # test instead of quietly moving the boundary.
1053
1189
  def actions_for(agent)
1054
1190
  return [] unless agent.respond_to?(:support_agent?) && agent.support_agent?
1191
+ # A machine's verbs are its policy's, filtered by the same state. Same
1192
+ # boundary, different vocabulary — see #assistant_actions_for.
1193
+ return assistant_actions_for(agent) if SupportDesk.ai_actor?(agent)
1055
1194
  # Off duty is a real answer: the console can still show the case, and
1056
1195
  # an agent passing by can still leave a note, but nothing that speaks
1057
1196
  # to the requester is offered to somebody who isn't working.
@@ -1070,6 +1209,11 @@ module SupportDesk
1070
1209
  actions << :release if assigned?
1071
1210
  actions << :change_topic
1072
1211
  actions << :close
1212
+ if pending_draft
1213
+ actions << :send_draft if may_reply?(agent) && !requester_unavailable?
1214
+ actions << :reject_draft
1215
+ end
1216
+ actions << (assistant_paused? ? :resume_assistant : :pause_assistant) if assistant
1073
1217
  end
1074
1218
  actions
1075
1219
  end
@@ -1077,6 +1221,10 @@ module SupportDesk
1077
1221
  # Whether +agent+ may answer right now under this desk's reply policy.
1078
1222
  def may_reply?(agent)
1079
1223
  return false unless agent.respond_to?(:support_agent?) && agent.support_agent?
1224
+ # A case a machine is holding is a case any person may answer,
1225
+ # whatever the desk says about assignees: `:assignee_only` exists so
1226
+ # two people don't answer at once, and she is not one (I8).
1227
+ return true if held_by_assistant? && !SupportDesk.ai_actor?(agent)
1080
1228
  return true unless desk_config.reply_policy == :assignee_only
1081
1229
 
1082
1230
  assigned_to?(agent)
@@ -1086,9 +1234,11 @@ module SupportDesk
1086
1234
  # the whole on-duty pool while it's unheld. The gem computes it; the
1087
1235
  # host delivers it.
1088
1236
  def agents_to_notify
1089
- return [ assignee ].compact if assigned?
1237
+ # A case the assistant holds is a case no person has seen, so the pool
1238
+ # hears about it: notifying a machine is notifying nobody.
1239
+ return [ assignee ].compact if assigned? && !held_by_assistant?
1090
1240
 
1091
- desk.on_duty_agents.to_a
1241
+ desk.on_duty_agents.to_a.reject { |agent| SupportDesk.ai_actor?(agent) }
1092
1242
  end
1093
1243
 
1094
1244
  # Generic on purpose: a lock screen shouldn't spell out what somebody's
@@ -1132,8 +1282,15 @@ module SupportDesk
1132
1282
  # Post a message as the DESK, signed by the agent who wrote it — every
1133
1283
  # answer, and the desk's first word when it writes first. One place knows
1134
1284
  # "the desk sends, the human signs".
1135
- def post_agent_message!(body, files: [], by:) # :nodoc:
1136
- desk.message!(conversation, body, files: files, author: by)
1285
+ # `metadata:` is the provenance a message carries: who drafted it, what
1286
+ # policy allowed it, what the machine declared about it. Posted through
1287
+ # `messages.create!` rather than `desk.message!` for one reason only —
1288
+ # chats' sugar takes no metadata, and the validations are the same.
1289
+ def post_agent_message!(body, files: [], by:, metadata: {}) # :nodoc:
1290
+ attributes = { sender: desk, body: body, author: by }
1291
+ attributes[:files] = files if files.present?
1292
+ attributes[:metadata] = metadata if metadata.present?
1293
+ conversation.messages.create!(**attributes)
1137
1294
  end
1138
1295
 
1139
1296
  def inspect
@@ -1170,10 +1327,44 @@ module SupportDesk
1170
1327
  end
1171
1328
 
1172
1329
  ensure_agent!(agent)
1330
+ return unless SupportDesk.ai_actor?(agent)
1331
+
1332
+ # "Could she hold this case once the human-side flags were lifted?" —
1333
+ # the hand-back question, so the very flags a hand-back exists to
1334
+ # clear are not what refuses it. Her autonomy, the topic and the host's
1335
+ # cap block still decide.
1336
+ policy = assistant_policy(agent, hand_back: true)
1337
+ raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
1338
+ end
1339
+
1340
+ # An assistant may take a case, and that is all: only herself, only when
1341
+ # nobody holds it, and only at a level that may answer.
1342
+ def ensure_assistant_may_assign!(actor, to)
1343
+ return unless SupportDesk.ai_actor?(actor)
1344
+
1345
+ assistant = resolve_assistant!(actor)
1346
+ policy = assistant_policy(assistant)
1347
+ unless self.class.same_actor?(actor, to)
1348
+ raise AssistantNotAllowed.new(policy, verb: :assign,
1349
+ message: "#{assistant.key} may not give #{reference} to " \
1350
+ "anybody — an assistant can only take a case herself")
1351
+ end
1352
+ raise AssistantNotAllowed.new(policy, verb: :take) unless unassigned?
1353
+ raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
1173
1354
  end
1174
1355
 
1175
1356
  def ensure_agent!(actor)
1176
1357
  return if actor.is_a?(Symbol)
1358
+ # An AI-kind actor that is not a SupportDesk::Assistant is refused on
1359
+ # every write, by it or to it (I2). A host model declared `kind: :ai`
1360
+ # is a machine the gem knows nothing about, and treating it as a human
1361
+ # agent — which is what 0.2 did — hands it every human's authority.
1362
+ if SupportDesk.ai_actor?(actor) && !actor.is_a?(SupportDesk::Assistant)
1363
+ raise NotAnAssistant,
1364
+ "#{describe_actor(actor)} is declared `acts_as_support_agent kind: :ai` but isn't this gem's " \
1365
+ "assistant — configure one with `config.assistant` and act as SupportDesk.assistant(key)"
1366
+ end
1367
+
1177
1368
  self.class.ensure_agent_record!(actor)
1178
1369
  end
1179
1370
 
@@ -1211,17 +1402,48 @@ module SupportDesk
1211
1402
  # `authorize:` is the console's hook (see Ticket.open_or_reply!): it runs
1212
1403
  # under this lock, before any policy side effect, and a raise there rolls
1213
1404
  # the whole thing back.
1214
- def reply_under_lock!(body, by:, files:, request:, authorize: nil)
1405
+ def reply_under_lock!(body, by:, files:, request:, authorize: nil, metadata: {}, turn: nil)
1215
1406
  with_lock(requires_new: true) do
1216
1407
  authorize&.call(self)
1408
+ next assistant_reply_under_lock!(body, assistant: by, files: files, request: request,
1409
+ metadata: metadata, turn: turn) if SupportDesk.ai_actor?(by)
1410
+
1217
1411
  ensure_writable!
1218
1412
  apply_reply_policy!(by, request: request)
1219
- posted = post_agent_message!(body, files: files, by: by)
1413
+ posted = post_agent_message!(body, files: files, by: by, metadata: metadata)
1414
+ # A person answering makes every machine proposal on this case out of
1415
+ # date — including one they are about to send, which is why the draft
1416
+ # being sent says so and is left alone.
1417
+ supersede_pending_drafts!(except: metadata.dig("support_desk", "draft_id"))
1220
1418
  record_registration!(posted)
1221
1419
  posted
1222
1420
  end
1223
1421
  end
1224
1422
 
1423
+ # `reply!` by the assistant: her full rules, under the lock the caller
1424
+ # already holds. Everything here is also what `respond!` runs — this is
1425
+ # the path for a host that decided to answer rather than ask policy.
1426
+ def assistant_reply_under_lock!(body, assistant:, files:, request:, metadata:, turn:)
1427
+ assistant = resolve_assistant!(assistant)
1428
+ # `:current` means "the turn as this lock sees it" — the one place the
1429
+ # gem supplies a turn itself, because an outreach reply into an
1430
+ # existing case has no earlier turn for anybody to have held.
1431
+ turn = assistant_turn if turn == :current
1432
+ reconcile_unregistered_messages!
1433
+ ensure_current_turn!(turn)
1434
+ ensure_writable!
1435
+
1436
+ policy = assistant_policy(assistant)
1437
+ raise AssistantNotAllowed.new(policy, verb: :reply) unless policy.may_reply?
1438
+ raise AssistantNotAllowed.new(policy, verb: :not_your_turn) unless awaiting_reply?
1439
+
1440
+ left = assistant_turns_left
1441
+ raise AssistantNotAllowed.new(policy, verb: :max_turns) if left&.zero?
1442
+
1443
+ speak!(body, assistant, policy: policy, turn: turn, files: files, confidence: nil, sources: [],
1444
+ metadata: metadata, request: request)
1445
+ end
1446
+
1225
1447
  def apply_reply_policy!(actor, request: nil)
1226
1448
  # A closed case has no seat to take: an agent adding one last word
1227
1449
  # posts it and the case stays closed. (A REQUESTER writing is what
@@ -1238,6 +1460,17 @@ module SupportDesk
1238
1460
 
1239
1461
  assign!(to: actor, by: actor, request: request)
1240
1462
  elsif !assigned_to?(actor)
1463
+ # Humans outrank assistants, under EVERY reply policy (I8). A person
1464
+ # answering a case a machine is holding takes it over: there is
1465
+ # nothing to ask about "who owns this" when one of the two can't
1466
+ # want it, and leaving her seated would keep her answering next.
1467
+ if held_by_assistant? && !SupportDesk.ai_actor?(actor)
1468
+ return assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
1469
+ end
1470
+ # Belt: the `held_by_human` floor already turned this into a draft,
1471
+ # so an assistant reaching here is a bug, not a policy question.
1472
+ raise AssistantNotAllowed.new(assistant_policy(actor), verb: :reply) if SupportDesk.ai_actor?(actor)
1473
+
1241
1474
  case policy
1242
1475
  when :assignee_only
1243
1476
  raise NotAllowed, "ticket #{reference} is held by #{describe_actor(assignee)} and this desk only " \
@@ -1270,7 +1503,11 @@ module SupportDesk
1270
1503
  payload = yield
1271
1504
  return nil if payload == false || payload.nil?
1272
1505
 
1273
- Event.record!(ticket: self, kind: kind, actor: actor, payload: payload.compact)
1506
+ event = Event.record!(ticket: self, kind: kind, actor: actor, payload: payload.compact)
1507
+ # A real transition changed the case, so it moves the turn — a
1508
+ # no-op one wrote no event and moves nothing.
1509
+ bump_assistant_revision!
1510
+ event
1274
1511
  end
1275
1512
 
1276
1513
  # The half that tells the world, once the write is durable. Every
@@ -1288,6 +1525,10 @@ module SupportDesk
1288
1525
  end
1289
1526
 
1290
1527
  def announce_assignment!(agent, first:)
1528
+ # "Rose se ocupa de tu consulta" is a promise about a person. Her
1529
+ # disclosure line is her announcement, and it is the only one.
1530
+ return if SupportDesk.ai_actor?(agent)
1531
+
1291
1532
  mode = desk_config.announce_assignments
1292
1533
  return if mode == :never
1293
1534
  return if mode == :first_only && !first
@@ -1321,10 +1562,16 @@ module SupportDesk
1321
1562
  reopen_event = nil
1322
1563
 
1323
1564
  attributes = { last_registered_message_id: message.id }
1565
+ closed_by_assistant = false
1324
1566
  case role
1325
1567
  when :requester
1326
1568
  attributes[:last_requester_message_at] = message.created_at
1569
+ # The pointer the turn's reconciliation reads: which requester
1570
+ # message the clocks are standing on, by id and not only by time.
1571
+ attributes[:last_requester_message_id] = message.id
1327
1572
  if closed? && message.created_at > closed_at && desk_config.closed_tickets == :reopen_on_reply
1573
+ # Read before the merge below clears it — see #reopen!.
1574
+ closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
1328
1575
  attributes.merge!(status: "open", closed_at: nil, closed_by: nil,
1329
1576
  reopen_count: reopen_count.to_i + 1, cardinality_key: "reopened:#{id}")
1330
1577
  reopened = true
@@ -1348,12 +1595,22 @@ module SupportDesk
1348
1595
  self.awaiting = closed? ? "none" : awaiting_from_clocks
1349
1596
  self.waiting_since = waiting_since_from_clocks
1350
1597
  save!
1598
+ # Every registered message moves the case on, so every one of them
1599
+ # moves the turn: an assistant holding the turn she read a second ago
1600
+ # is holding a case that has not changed since (I4).
1601
+ bump_assistant_revision!
1351
1602
 
1352
1603
  if reopened
1353
- restore_assignment!(by: :system)
1354
- reopen_event = record_transition!(:reopened, actor: requester) { { "via" => "requester_reply" } }
1604
+ capped = restore_assignment!(by: :system, closed_by_assistant: closed_by_assistant)
1605
+ reopen_event = record_transition!(:reopened, actor: requester) do
1606
+ { "via" => "requester_reply", "assistant_capped" => (true if capped) }.compact
1607
+ end
1355
1608
  end
1356
1609
 
1610
+ # After the bump, and after any reopen: the hook decides about a case
1611
+ # in the state this message left it in.
1612
+ evaluate_hand_off_phrase!(message) if role == :requester
1613
+
1357
1614
  publish_transition(reopen_event, :reopened, requester, nil) if reopen_event
1358
1615
  announce_registration(message, role: role, opening: opening, reopened: reopened)
1359
1616
  self
@@ -1372,12 +1629,26 @@ module SupportDesk
1372
1629
  def registered?(message)
1373
1630
  return true if last_registered_message_id.present? && last_registered_message_id.to_s == message.id.to_s
1374
1631
 
1375
- clock = case role_of(message)
1632
+ role = role_of(message)
1633
+ clock = case role
1376
1634
  when :requester then last_requester_message_at
1377
1635
  when :agent then last_agent_message_at
1378
1636
  end
1379
-
1380
- clock.present? && message.created_at <= clock
1637
+ return false if clock.blank?
1638
+ return true if message.created_at < clock
1639
+ return false unless message.created_at == clock
1640
+
1641
+ # Equal timestamps are NOT the same message. A coarse column, a frozen
1642
+ # clock in a test, or two very fast inserts can put two different
1643
+ # messages on one instant, and a `<=` here folded the second one in
1644
+ # without moving anything — so the assistant answered a question the
1645
+ # case had never registered. The only equal-timestamp message that
1646
+ # counts as folded in is the one the clock was set FROM.
1647
+ case role
1648
+ when :requester then last_requester_message_id.present? &&
1649
+ last_requester_message_id.to_s == message.id.to_s
1650
+ else false
1651
+ end
1381
1652
  end
1382
1653
 
1383
1654
  def opening_message?
@@ -1432,14 +1703,26 @@ module SupportDesk
1432
1703
  # disagree on a live ticket. A closed ticket keeps its assignee as the
1433
1704
  # record of who dealt with it, with no open row — that pair is the one
1434
1705
  # shape `doctor` expects to see.
1435
- def restore_assignment!(by:)
1436
- return if unassigned?
1706
+ def restore_assignment!(by:, closed_by_assistant: false)
1707
+ return false if unassigned?
1708
+
1709
+ # A case the assistant closed and the customer reopened is a case she
1710
+ # got wrong (12 #21). It comes back to PEOPLE — unassigned, and capped
1711
+ # at :draft on this case for good, whatever her level is elsewhere. An
1712
+ # explicit hand-back is the only thing that lifts it, and the cap can
1713
+ # only tighten: an existing :observe stays :observe.
1714
+ if closed_by_assistant
1715
+ cap = [ assistant_cap&.to_sym, :draft ].compact.min_by { |level| AssistantPolicy::RANK.fetch(level) }
1716
+ update!(assignee: nil, assistant_cap: cap.to_s)
1717
+ return true
1718
+ end
1437
1719
 
1438
1720
  if assignee.respond_to?(:support_agent?) && assignee.support_agent?
1439
1721
  Assignment.open!(ticket: self, agent: assignee, by: by, reason: :reopened)
1440
1722
  else
1441
1723
  update!(assignee: nil)
1442
1724
  end
1725
+ false
1443
1726
  end
1444
1727
 
1445
1728
  def awaiting_from_clocks
@@ -1464,6 +1747,10 @@ module SupportDesk
1464
1747
 
1465
1748
  case role
1466
1749
  when :requester
1750
+ # The opening message included: a case that starts with a question
1751
+ # is a case with something to answer, and the harness should hear
1752
+ # about it the same way it hears about every later message.
1753
+ emit_assistant_turn(message)
1467
1754
  SupportDesk.emit_after_commit(:requester_replied, self, message) unless opening
1468
1755
  when :agent
1469
1756
  # The desk's OWN first word announces nothing: it is the start of a
@@ -1497,11 +1784,21 @@ module SupportDesk
1497
1784
  conversation.messages.visible.oldest_first.map do |message|
1498
1785
  {
1499
1786
  at: message.created_at,
1500
- from: role_of(message) == :requester ? "you" : "support",
1787
+ # "assistant" in every disclosure mode, silent ones included: what
1788
+ # a customer was told is a product decision, what an export says a
1789
+ # machine wrote is not.
1790
+ from: export_from(message),
1501
1791
  body: message.visible_body,
1502
1792
  attachments: message.try(:files)&.map { |file| file.try(:filename).to_s } || []
1503
1793
  }
1504
1794
  end
1505
1795
  end
1796
+
1797
+ def export_from(message)
1798
+ return "you" if role_of(message) == :requester
1799
+ return "assistant" if assistant_message?(message)
1800
+
1801
+ "support"
1802
+ end
1506
1803
  end
1507
1804
  end