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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +998 -14
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +23 -1
- data/app/helpers/support_desk/engine_helper.rb +16 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
- data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
- data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- data/app/views/support_desk/console/tickets/show.html.erb +5 -0
- data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
- data/config/locales/support_desk.console.en.yml +63 -0
- data/config/locales/support_desk.console.es.yml +65 -0
- data/config/locales/support_desk.en.yml +15 -0
- data/config/locales/support_desk.es.yml +23 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/install_generator.rb +12 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
- data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
- data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
- data/lib/generators/support_desk/templates/initializer.rb +33 -0
- data/lib/generators/support_desk/upgrade_generator.rb +12 -2
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +552 -2
- data/lib/support_desk/console.rb +290 -8
- data/lib/support_desk/context_card.rb +10 -1
- data/lib/support_desk/doctor.rb +144 -1
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +37 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +14 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/requester.rb +18 -0
- data/lib/support_desk/models/desk.rb +26 -3
- data/lib/support_desk/models/draft.rb +266 -0
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +675 -0
- data/lib/support_desk/models/ticket.rb +334 -37
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/test_helpers.rb +152 -0
- data/lib/support_desk/timeline.rb +40 -11
- data/lib/support_desk/topic.rb +22 -0
- data/lib/support_desk/topic_tree.rb +7 -1
- data/lib/support_desk/transcript.rb +237 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +108 -0
- data/lib/tasks/support_desk.rake +46 -0
- 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
|