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
@@ -22,6 +22,8 @@ en:
22
22
  topic: "Topic"
23
23
  subject_status: "Status"
24
24
  open_subject: "Open in admin"
25
+ assistant: "Assistant"
26
+ requester_context: "About this person"
25
27
  transcript:
26
28
  title: "Conversation"
27
29
  empty: "Nothing has been said yet."
@@ -44,6 +46,41 @@ en:
44
46
  note_hint: "Notes never leave the desk."
45
47
  closed: "This case is closed. Reopen it to answer."
46
48
  assignee_only: "%{agent} is handling this case, and this desk only lets the assignee reply."
49
+ draft:
50
+ title: "%{name}'s proposal"
51
+ from: "%{name}'s proposal"
52
+ confidence: "declared confidence %{percent} %"
53
+ sources: "Sources"
54
+ attachments: "Attachments"
55
+ stale: "The conversation has changed since this was proposed. Read it before you send."
56
+ editing: "Editing %{name}'s proposal"
57
+ cancel_edit: "cancel"
58
+ reason_placeholder: "Why are you discarding it? (optional)"
59
+ sent_by: "Proposal sent by %{name}"
60
+ rejected_by: "Proposal discarded by %{name}"
61
+ # The verb the "already %{status}" refusal reads.
62
+ statuses:
63
+ pending: "proposed"
64
+ sent: "sent"
65
+ rejected: "discarded"
66
+ superseded: "replaced"
67
+ expired: "expired"
68
+ badges:
69
+ needs_human: "Needs a person"
70
+ pending_draft: "Proposal ready"
71
+ assistant_holds: "%{name} has it ยท %{used}/%{max} turns"
72
+ assistant_holds_unlimited: "%{name} has it"
73
+ withheld_reasons:
74
+ policy: "this case's policy doesn't allow it"
75
+ not_your_turn: "it wasn't her turn"
76
+ # Why a case ended up with a person. The tokens are written by the gem
77
+ # or by the host's own harness; these are how they read.
78
+ escalation_reasons:
79
+ max_turns: "she ran out of turns"
80
+ assistant_silent: "the assistant didn't answer in time"
81
+ requester_request: "the customer asked for one"
82
+ phrase: "the customer asked in the conversation"
83
+ hand_off_when_error: "the assistant failed"
47
84
  assignment:
48
85
  title: "Who has it"
49
86
  held_by: "Held by %{agent}"
@@ -54,6 +91,16 @@ en:
54
91
  timeline:
55
92
  title: "Timeline"
56
93
  system: "System"
94
+ escalated: "%{actor} handed the case to a person: %{reason}"
95
+ human_requested: "The customer asked for a person"
96
+ assistant_paused: "%{actor} paused the assistant"
97
+ assistant_resumed: "%{actor} resumed the assistant"
98
+ draft_sent: "%{actor} sent %{assistant}'s proposal"
99
+ draft_sent_edited: "%{actor} sent %{assistant}'s proposal (edited)"
100
+ draft_rejected: "%{actor} discarded %{assistant}'s proposal"
101
+ assistant_withheld: "%{assistant} didn't answer: %{reason}"
102
+ draft_superseded: "Proposal replaced"
103
+ draft_expired: "Proposal expired when the case closed"
57
104
  kinds:
58
105
  message: "Message"
59
106
  opened: "Opened"
@@ -79,6 +126,12 @@ en:
79
126
  close: "Close"
80
127
  reopen: "Reopen"
81
128
  change_topic: "Refile"
129
+ send_draft: "Send"
130
+ send_edited_draft: "Send the edited proposal"
131
+ edit_draft: "Edit"
132
+ reject_draft: "Discard"
133
+ pause_assistant: "Pause the assistant"
134
+ resume_assistant: "Resume the assistant"
82
135
  flashes:
83
136
  message_sent: "Message sent to %{requester}."
84
137
  replied: "Answer sent."
@@ -91,6 +144,10 @@ en:
91
144
  noted: "Note saved."
92
145
  topic_changed: "Refiled."
93
146
  queue_empty: "Nothing is waiting for you."
147
+ draft_sent: "Proposal sent."
148
+ draft_rejected: "Proposal discarded."
149
+ assistant_paused: "The assistant is paused on this case."
150
+ assistant_resumed: "The assistant is back on this case."
94
151
  errors:
95
152
  forbidden: "You don't have access to the support console."
96
153
  not_allowed: "You can't do that on this case."
@@ -116,4 +173,10 @@ en:
116
173
  writing_to_yourself: "You can't open a support conversation with yourself. Ask for help as a customer instead."
117
174
  off_duty: "You're off duty, so you can't write to anybody right now."
118
175
  attachments_again: "If you attach files and something goes wrong, you'll have to pick them again."
176
+ unknown_draft: "That proposal is gone."
177
+ draft_already_reviewed: "That proposal was already %{status}."
178
+ no_pending_draft: "There is no proposal waiting on this case."
179
+ stale_turn: "The conversation has changed. Read it and send again."
180
+ assistant_not_allowed: "The assistant canโ€™t do that on this case."
181
+ not_an_assistant: "That action is the assistantโ€™s only."
119
182
  generic: "That didn't work."
@@ -22,6 +22,8 @@ es:
22
22
  topic: "Tema"
23
23
  subject_status: "Estado"
24
24
  open_subject: "Abrir en el admin"
25
+ assistant: "Asistente"
26
+ requester_context: "Sobre la persona"
25
27
  transcript:
26
28
  title: "Conversaciรณn"
27
29
  empty: "Todavรญa no se ha dicho nada."
@@ -44,6 +46,43 @@ es:
44
46
  note_hint: "Las notas nunca salen del equipo."
45
47
  closed: "Este caso estรก cerrado. Vuelve a abrirlo para responder."
46
48
  assignee_only: "%{agent} lleva este caso y en esta mesa solo responde quien lo tiene."
49
+ draft:
50
+ title: "Propuesta de %{name}"
51
+ from: "propuesta de %{name}"
52
+ confidence: "confianza declarada %{percent} %"
53
+ sources: "Fuentes"
54
+ attachments: "Adjuntos"
55
+ stale: "La conversaciรณn ha cambiado desde esta propuesta. Lรฉela antes de enviar."
56
+ editing: "Editando la propuesta de %{name}"
57
+ cancel_edit: "cancelar"
58
+ reason_placeholder: "ยฟPor quรฉ la descartas? (opcional)"
59
+ sent_by: "Propuesta enviada por %{name}"
60
+ rejected_by: "Propuesta descartada por %{name}"
61
+ # The verb the "ya se %{status}" refusal reads. Never the English
62
+ # status: a Spanish desk reading "ya se sent" is the whole reason
63
+ # this map exists.
64
+ statuses:
65
+ pending: "propuso"
66
+ sent: "enviรณ"
67
+ rejected: "descartรณ"
68
+ superseded: "sustituyรณ"
69
+ expired: "caducรณ"
70
+ badges:
71
+ needs_human: "Necesita persona"
72
+ pending_draft: "Propuesta lista"
73
+ assistant_holds: "%{name} se ocupa ยท %{used}/%{max} turnos"
74
+ assistant_holds_unlimited: "%{name} se ocupa"
75
+ withheld_reasons:
76
+ policy: "la polรญtica de este caso no se lo permite"
77
+ not_your_turn: "no era su turno"
78
+ # Por quรฉ un caso acabรณ en manos de una persona. Los tokens los
79
+ # escribe el gem o el harness del host; aquรญ se leen en castellano.
80
+ escalation_reasons:
81
+ max_turns: "se quedรณ sin turnos"
82
+ assistant_silent: "el asistente no contestรณ a tiempo"
83
+ requester_request: "lo pidiรณ el cliente"
84
+ phrase: "el cliente lo pidiรณ en la conversaciรณn"
85
+ hand_off_when_error: "el asistente fallรณ"
47
86
  assignment:
48
87
  title: "Quiรฉn lo lleva"
49
88
  held_by: "Lo lleva %{agent}"
@@ -54,6 +93,16 @@ es:
54
93
  timeline:
55
94
  title: "Historial"
56
95
  system: "Sistema"
96
+ escalated: "%{actor} pasรณ el caso a una persona: %{reason}"
97
+ human_requested: "El cliente pidiรณ hablar con una persona"
98
+ assistant_paused: "%{actor} pausรณ el asistente"
99
+ assistant_resumed: "%{actor} reanudรณ el asistente"
100
+ draft_sent: "%{actor} enviรณ la propuesta de %{assistant}"
101
+ draft_sent_edited: "%{actor} enviรณ la propuesta de %{assistant} (editada)"
102
+ draft_rejected: "%{actor} descartรณ la propuesta de %{assistant}"
103
+ assistant_withheld: "%{assistant} no respondiรณ: %{reason}"
104
+ draft_superseded: "Propuesta sustituida"
105
+ draft_expired: "Propuesta caducada al cerrar"
57
106
  kinds:
58
107
  message: "Mensaje"
59
108
  opened: "Abierto"
@@ -79,6 +128,12 @@ es:
79
128
  close: "Cerrar"
80
129
  reopen: "Reabrir"
81
130
  change_topic: "Cambiar tema"
131
+ send_draft: "Enviar"
132
+ send_edited_draft: "Enviar propuesta editada"
133
+ edit_draft: "Editar"
134
+ reject_draft: "Descartar"
135
+ pause_assistant: "Pausar asistente"
136
+ resume_assistant: "Reanudar asistente"
82
137
  flashes:
83
138
  message_sent: "Mensaje enviado a %{requester}."
84
139
  replied: "Respuesta enviada."
@@ -91,6 +146,10 @@ es:
91
146
  noted: "Nota guardada."
92
147
  topic_changed: "Tema cambiado."
93
148
  queue_empty: "No te espera nada."
149
+ draft_sent: "Propuesta enviada."
150
+ draft_rejected: "Propuesta descartada."
151
+ assistant_paused: "Asistente pausado en este caso."
152
+ assistant_resumed: "Asistente reanudado en este caso."
94
153
  errors:
95
154
  forbidden: "No tienes acceso a la consola de soporte."
96
155
  not_allowed: "No puedes hacer eso en este caso."
@@ -116,4 +175,10 @@ es:
116
175
  writing_to_yourself: "No puedes abrir una conversaciรณn de soporte contigo. Si necesitas ayuda, pรญdela como cliente."
117
176
  off_duty: "No estรกs de guardia, asรญ que no puedes escribir a nadie ahora mismo."
118
177
  attachments_again: "Si adjuntas archivos y algo falla, tendrรกs que volver a seleccionarlos."
178
+ unknown_draft: "Esa propuesta ya no existe."
179
+ draft_already_reviewed: "Esa propuesta ya se %{status}."
180
+ no_pending_draft: "No hay ninguna propuesta pendiente en este caso."
181
+ stale_turn: "La conversaciรณn ha cambiado. Lรฉela y vuelve a enviar."
182
+ assistant_not_allowed: "El asistente no puede hacer eso en este caso."
183
+ not_an_assistant: "Esa acciรณn es solo para el asistente."
119
184
  generic: "No ha funcionado."
@@ -3,17 +3,31 @@ en:
3
3
  system:
4
4
  assigned: "%{agent} is taking care of your request"
5
5
  reassigned: "%{agent} is now taking care of your request"
6
+ assistant_disclosure: "%{name} is %{desk}โ€™s virtual assistant. If youโ€™d rather talk to a person, use the button in this conversation."
7
+ handed_off_to_humans: "Iโ€™m passing you to somebody on the team."
8
+ handed_off_to_humans_with_promise: "Iโ€™m passing you to somebody on the team. We usually reply in under %{reply_within}."
9
+ human_requested: "Somebody from the team will take it from here."
10
+ human_requested_with_promise: "Somebody from the team will take it from here. We usually reply in under %{reply_within}."
11
+ assistant:
12
+ disclosed_name: "%{name} ยท virtual assistant"
6
13
  thread:
7
14
  closed_notice: "This conversation is closed. Start a new one if you need anything else."
8
15
  reopened_notice: "We reopened this conversation."
9
16
  promise: "We usually reply in under %{time}"
10
17
  opened_by_support: "%{desk} opened this conversation with you about โ€œ%{label}โ€. You can reply right here."
11
18
  unavailable_notice: "This account can no longer send or receive messages. The conversation stays here to read."
19
+ human_door: "Iโ€™d rather talk to a person"
20
+ human_requested_notice: "Done. Somebody from the team will take it from here."
21
+ handled_by_assistant: "Handled by %{name}"
22
+ handled_by_humans: "Somebody from the team will take it from here"
23
+ transcript:
24
+ deleted: "[message deleted]"
12
25
  notifications:
13
26
  title: "%{desk} ยท new message"
14
27
  queue:
15
28
  tabs:
16
29
  awaiting: "Needs a reply"
30
+ needs_human: "Needs a person"
17
31
  mine: "Mine"
18
32
  unassigned: "Unassigned"
19
33
  open: "Open"
@@ -21,6 +35,7 @@ en:
21
35
  closed: "Closed"
22
36
  summary:
23
37
  awaiting_reply: "awaiting reply"
38
+ needs_human: "needs a person"
24
39
  awaiting_requester: "awaiting requester"
25
40
  open: "open"
26
41
  closed: "closed"
@@ -4,6 +4,17 @@ es:
4
4
  system:
5
5
  assigned: "%{agent} se ocupa de tu consulta"
6
6
  reassigned: "%{agent} se ocupa ahora de tu consulta"
7
+ # Las tres lรญneas del asistente. Cada una tiene su variante con
8
+ # promesa: una mesa que no promete un plazo no debe inventarlo.
9
+ assistant_disclosure: "%{name} es el asistente virtual de %{desk}. Si prefieres hablar con una persona, usa el botรณn de esta conversaciรณn."
10
+ handed_off_to_humans: "Te paso con una persona del equipo."
11
+ handed_off_to_humans_with_promise: "Te paso con una persona del equipo. Normalmente contestamos en menos de %{reply_within}."
12
+ human_requested: "Una persona del equipo seguirรก con tu consulta."
13
+ human_requested_with_promise: "Una persona del equipo seguirรก con tu consulta. Normalmente contestamos en menos de %{reply_within}."
14
+ assistant:
15
+ # La divulgaciรณn no es una insignia que alguien pueda no ver: cuando
16
+ # se divulga, el nombre mismo dice quรฉ es.
17
+ disclosed_name: "%{name} ยท asistente virtual"
7
18
  thread:
8
19
  closed_notice: "Esta conversaciรณn estรก cerrada. Abre una nueva si necesitas algo mรกs."
9
20
  reopened_notice: "Hemos vuelto a abrir esta conversaciรณn."
@@ -13,6 +24,16 @@ es:
13
24
  # quรฉ es esto.
14
25
  opened_by_support: "%{desk} ha abierto esta conversaciรณn contigo sobre ยซ%{label}ยป. Puedes responder aquรญ mismo."
15
26
  unavailable_notice: "Esta cuenta ya no puede enviar ni recibir mensajes. La conversaciรณn se queda aquรญ para consultarla."
27
+ human_door: "Prefiero hablar con una persona"
28
+ human_requested_notice: "Hecho. Una persona del equipo seguirรก con tu consulta."
29
+ handled_by_assistant: "Atendida por %{name}"
30
+ handled_by_humans: "Una persona del equipo seguirรก con tu consulta"
31
+ transcript:
32
+ # Un mensaje borrado sigue siendo un turno. Quien lea la conversaciรณn
33
+ # despuรฉs, una persona o el modelo al que el host se la pasa, tiene
34
+ # que ver que ahรญ se dijo algo y se retirรณ: un hueco sin marcar se lee
35
+ # como que nunca pasรณ nada.
36
+ deleted: "[mensaje eliminado]"
16
37
  notifications:
17
38
  # Generic on purpose: una pantalla de bloqueo no debe contar de quรฉ va
18
39
  # el caso de nadie. El detalle va en el cuerpo.
@@ -20,6 +41,7 @@ es:
20
41
  queue:
21
42
  tabs:
22
43
  awaiting: "Pendientes"
44
+ needs_human: "Necesitan persona"
23
45
  mine: "Mi cola"
24
46
  unassigned: "Sin asignar"
25
47
  open: "Abiertos"
@@ -27,6 +49,7 @@ es:
27
49
  closed: "Cerrados"
28
50
  summary:
29
51
  awaiting_reply: "esperando respuesta"
52
+ needs_human: "necesita una persona"
30
53
  awaiting_requester: "esperando al cliente"
31
54
  open: "abierto"
32
55
  closed: "cerrado"
data/config/routes.rb CHANGED
@@ -18,7 +18,13 @@ SupportDesk::Engine.routes.draw do
18
18
  # `show` redirects into the chats conversation โ€” a stable URL for a case,
19
19
  # for emails and notifications, that never has to know where chats is
20
20
  # mounted.
21
- resources :tickets, only: %i[create show]
21
+ resources :tickets, only: %i[create show] do
22
+ # The door out of a machine. It is a POST on the CASE rather than
23
+ # anything to do with chats, because it is a fact about the case: from
24
+ # here on a person is expected, whatever the assistant would have done
25
+ # next.
26
+ post :request_human, on: :member
27
+ end
22
28
 
23
29
  root to: "tickets#index"
24
30
  end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module SupportDesk
6
+ module Generators
7
+ # `rails generate support_desk:assistant Rose --disclosure signature` โ€”
8
+ # the harness, as files in YOUR app.
9
+ #
10
+ # The gem never calls a model. It emits `:assistant_turn` and it accepts
11
+ # `respond!` / `draft!` / `escalate!` / `note!`; everything between those
12
+ # two points โ€” which provider, which prompt, which retries, what it
13
+ # costs โ€” is yours. This generator writes that middle: a job wired to
14
+ # the event and a service with one method to fill in.
15
+ #
16
+ # Three things it deliberately does NOT do:
17
+ #
18
+ # * It never touches your initializer. `config.assistant` is a policy
19
+ # decision, and a generator that appends one turns "let me look at
20
+ # this" into a live assistant. The stanza is PRINTED; you paste it.
21
+ # * It never overwrites. Run it again after an upgrade and it tells you
22
+ # which files it left alone.
23
+ # * It copies no locales. The copy an assistant speaks in is the gem's
24
+ # (`support_desk.system.*`) until you override those keys in your own
25
+ # locale file, where they belong.
26
+ #
27
+ # `--disclosure` is required and has no default. Whether a customer is
28
+ # told they are talking to a machine is not a thing a gem gets to decide
29
+ # for a host in a jurisdiction it knows nothing about, so the generator
30
+ # refuses to write anything until somebody has chosen.
31
+ class AssistantGenerator < Rails::Generators::Base
32
+ # The four modes, and what each one does to what the requester sees.
33
+ DISCLOSURE_MODES = {
34
+ "signature_and_notice" => "she signs every message AND the thread opens with a notice",
35
+ "signature" => "she signs every message; no notice",
36
+ "notice" => "the thread opens with a notice; her messages are unsigned, from the desk",
37
+ "none" => "nothing is said and nothing is signed โ€” your legal process has to be the one that chose this"
38
+ }.freeze
39
+
40
+ source_root File.expand_path("templates/assistant", __dir__)
41
+
42
+ desc "Generate the harness for a support assistant: a turn job and a service"
43
+
44
+ argument :name, type: :string, banner: "Rose",
45
+ desc: "What she is called. Becomes her key (rose), her service and her job"
46
+
47
+ class_option :disclosure, type: :string, required: false, banner: "MODE",
48
+ desc: "REQUIRED: #{DISCLOSURE_MODES.keys.join(" | ")}"
49
+ class_option :desk, type: :string, default: "default",
50
+ desc: "The desk she works"
51
+ class_option :autonomy, type: :string, default: "draft",
52
+ desc: AssistantPolicy::LEVELS.join(" | ")
53
+
54
+ # Everything is checked BEFORE the first file is written. A generator
55
+ # that writes two of its three files and then refuses the third leaves
56
+ # an app that neither boots nor regenerates.
57
+ def validate_options!
58
+ @refused = true
59
+ return say_disclosure_modes if options[:disclosure].blank?
60
+
61
+ unless DISCLOSURE_MODES.key?(disclosure)
62
+ say_status :error, "unknown disclosure #{options[:disclosure].inspect}", :red
63
+ return say_disclosure_modes
64
+ end
65
+ unless AssistantPolicy::LEVELS.map(&:to_s).include?(autonomy)
66
+ say_status :error, "unknown autonomy #{options[:autonomy].inspect} โ€” one of " \
67
+ "#{AssistantPolicy::LEVELS.join(", ")}", :red
68
+ return
69
+ end
70
+
71
+ @refused = false
72
+ end
73
+
74
+ def create_turn_job
75
+ return if refused?
76
+
77
+ template "turn_job.rb.erb", "app/jobs/support/#{key}_turn_job.rb", skip: true
78
+ end
79
+
80
+ def create_service
81
+ return if refused?
82
+
83
+ template "service.rb.erb", "app/services/support/#{key}.rb", skip: true
84
+ end
85
+
86
+ def create_job_test
87
+ return if refused?
88
+
89
+ template "turn_job_test.rb.erb", "test/jobs/support/#{key}_turn_job_test.rb", skip: true
90
+ end
91
+
92
+ # The four things a host still has to do by hand, in the order they
93
+ # have to happen. Printed, never written: each one is a decision.
94
+ def display_post_install_message
95
+ return if refused?
96
+
97
+ say "\n๐Ÿค– #{assistant_name}'s harness is in app/jobs/support/ and app/services/support/.", :green
98
+ say "\n Existing files were left alone โ€” nothing here is ever overwritten."
99
+
100
+ say "\n 1. Paste this into config/initializers/support_desk.rb, inside the configure block:"
101
+ say "\n#{initializer_stanza}"
102
+
103
+ say " 2. Subscribe the job to the one event the gem emits:"
104
+ say "\n#{subscription_stanza}"
105
+
106
+ say " 3. Schedule the two maintenance tasks. They are what makes a dead harness"
107
+ say " a delay instead of a customer nobody answers:"
108
+ say "\n#{scheduler_stanza}"
109
+
110
+ say " 4. Write #{service_class}.answer โ€” it raises NotImplementedError until you do."
111
+ say " The README's Assistants section is the whole contract."
112
+
113
+ say "\n Then: SupportDesk.doctor.print, and start at --autonomy draft. Every word"
114
+ say " goes through a person until the numbers say otherwise.\n", :green
115
+ end
116
+
117
+ private
118
+
119
+ def refused? = @refused
120
+
121
+ # "Rose" โ†’ "rose"; "Customer Care" โ†’ "customer_care".
122
+ def key = name.to_s.strip.underscore.parameterize(separator: "_")
123
+
124
+ # What she is called, as typed โ€” the `name` setting, not the key.
125
+ def assistant_name = name.to_s.strip
126
+
127
+ def service_class = "Support::#{key.camelize}"
128
+ def job_class = "Support::#{key.camelize}TurnJob"
129
+ def disclosure = options[:disclosure].to_s.strip
130
+ def autonomy = options[:autonomy].to_s.strip
131
+ def desk_key = options[:desk].to_s.strip.presence || "default"
132
+ def default_desk? = desk_key == "default"
133
+
134
+ def say_disclosure_modes
135
+ say "\nsupport_desk:assistant needs --disclosure. Nothing was written.", :yellow
136
+ say "\n Whether a customer is told they are talking to a machine is a legal and"
137
+ say " product decision, and it is yours. The four modes:\n\n"
138
+ DISCLOSURE_MODES.each { |mode, meaning| say " --disclosure #{mode.ljust(21)} #{meaning}" }
139
+ say "\n rails g support_desk:assistant #{name} --disclosure signature\n\n"
140
+ end
141
+
142
+ def initializer_stanza
143
+ lines = [
144
+ " config.assistant :#{key} do |#{key}|",
145
+ " #{key}.name = #{assistant_name.inspect}",
146
+ " #{key}.autonomy = :#{autonomy}",
147
+ " #{key}.disclosure = :#{disclosure}",
148
+ " #{key}.max_turns = 6",
149
+ " #{key}.responds_within = 3.minutes",
150
+ " # #{key}.hand_off_when { |_ticket, message| " \
151
+ "message.body.to_s.match?(/\\b(persona|humano|agente)\\b/i) }",
152
+ " # #{key}.cap { |ticket| :draft if ticket.requester.try(:vip?) }",
153
+ " end"
154
+ ]
155
+ lines << if default_desk?
156
+ " config.default_assistant = :#{key}"
157
+ else
158
+ " config.desk(:#{desk_key}) { |desk| desk.assistant = :#{key} }"
159
+ end
160
+ "#{lines.join("\n")}\n\n"
161
+ end
162
+
163
+ def subscription_stanza
164
+ <<~RUBY
165
+
166
+ SupportDesk.on(:assistant_turn, key: "support.#{key}.turn") do |ticket, assistant, _message, turn:|
167
+ #{job_class}.set(wait: 20.seconds).perform_later(ticket.id, assistant.key, turn)
168
+ end
169
+
170
+ RUBY
171
+ end
172
+
173
+ def scheduler_stanza
174
+ <<~YAML
175
+
176
+ # config/recurring.yml (solid_queue)
177
+ production:
178
+ support_desk_release_silent_assistants:
179
+ command: "SupportDesk.release_silent_assistants!"
180
+ schedule: every minute
181
+ support_desk_redispatch_assistant_turns:
182
+ command: "SupportDesk.redispatch_assistant_turns!"
183
+ schedule: every 5 minutes
184
+
185
+ # or cron
186
+ * * * * * cd /app && bin/rails support_desk:release_silent_assistants
187
+ */5 * * * * cd /app && bin/rails support_desk:redispatch_assistant_turns
188
+
189
+ YAML
190
+ end
191
+ end
192
+ end
193
+ end
@@ -36,6 +36,14 @@ module SupportDesk
36
36
  File.join(db_migrate_path, "add_opened_by_to_support_desk_tickets.rb")
37
37
  end
38
38
 
39
+ # The assistants (0.3). The SAME file `support_desk:upgrade` copies,
40
+ # for the same reason: one migration owns those tables and columns
41
+ # wherever they came from.
42
+ def create_assistants_migration
43
+ migration_template "add_assistants_to_support_desk.rb.erb",
44
+ File.join(db_migrate_path, "add_assistants_to_support_desk.rb")
45
+ end
46
+
39
47
  # The annotated initializer โ€” every setting the gem has, with what it
40
48
  # means and what it defaults to.
41
49
  def create_initializer
@@ -86,6 +94,10 @@ module SupportDesk
86
94
  say " nothing you own. Follow the CHANGELOG's drained cutover: migrate, pause"
87
95
  say " support traffic, drain old web/workers, backfill, then serve only 0.2."
88
96
 
97
+ say "\n Assistants (0.3): when a machine should answer first, 'rails g support_desk:assistant"
98
+ say " Rose --disclosure signature' writes the harness and prints the stanza. Read the"
99
+ say " README's Assistants section before you flip it on โ€” disclosure is a required choice."
100
+
89
101
  say "\nCheck your work any time with SupportDesk.doctor.print"
90
102
  say "You now have support tickets that are real conversations. ๐Ÿš€\n", :green
91
103
  end