Skip to content

Commit

Permalink
IVR simulator breakoff on first question (#2069)
Browse files Browse the repository at this point in the history
* Extract sms, ivr and mobileweb simulators to distinct files

* Fix: refactor timeout/repeat in IVR simulator

Instead of handling the repetitions in the frontend and sending an
eventual STOP message to trick the simulator to breakoff, we send a
timeout message (similar to how the Verboice channel operates) to
tell the simulator (and its associated survey session) that we
didn't get a reply, which will trigger Surveda's retry mecanism,
which is to repeat the question twice before failing the session.

The behavior of the IVR simulator is now much closer to the reality.

It fixes the issue where a Contacted/Unresponsive simulation (no
answer) was flagged as a Responsive/Refused. It also avoids having a
STOP message to appear in the message history.

* Use optional Message.type property instead of Message|string union

* IVR simulator: hangup should stop the simulation

Replaces the usage of the `stop` message with a proper hangup
message that will eventually fail the current simulation session,
properly changing the disposition.
  • Loading branch information
ysbaddaden authored Apr 28, 2022
1 parent 4dc63bc commit 3ac99be
Show file tree
Hide file tree
Showing 7 changed files with 577 additions and 434 deletions.
18 changes: 8 additions & 10 deletions assets/js/components/simulation/QuestionnaireSimulation.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,18 +197,16 @@ class QuestionnaireSimulation extends Component<Props, State> {
handleATMessage = (message) => {
const { projectId, questionnaireId, mode } = this.props
const { simulation } = this.state
if (simulation) {

if (!simulation) return

if (message.type === "at") {
this.addMessage(message)
messageSimulation(
projectId,
questionnaireId,
simulation.respondentId,
message.body,
mode
).then((result) => {
this.onSimulationChanged(result)
})
}

messageSimulation(projectId, questionnaireId, simulation.respondentId, message.body, mode).then(
(result) => this.onSimulationChanged(result)
)
}

addMessage(message) {
Expand Down
103 changes: 50 additions & 53 deletions assets/js/components/simulation/VoiceWindow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { MessagesList, ChatMessage } from "./MessagesList"

type Message = {
body: string,
type: string,
type?: string,
}

export type IVRPrompt = {
Expand Down Expand Up @@ -39,15 +39,12 @@ const VoiceWindow = translate()(
spectrum: VoiceSpectrum

message: string
messageTimer: TimeoutID

repeatMessageTimer: ?TimeoutID
timesRepeated: number
messageTimer: ?TimeoutID
answerTimeout: ?TimeoutID

constructor(props) {
super(props)
this.spectrum = new VoiceSpectrum()
this.timesRepeated = 0
}

componentDidMount() {
Expand All @@ -62,13 +59,12 @@ const VoiceWindow = translate()(
}

componentWillUnmount() {
if (this.spectrum) {
this.spectrum.stop()
}

this.stopRepeatingLastAudio()
if (this.spectrum) this.spectrum.stop()
this.stopSimulation()
}

// Plays the current prompts in the order the simulator tells us to.
// Eventually starts waiting for an answer message to send.
play() {
const ivr = this.props.prompts.shift()
if (ivr) {
Expand All @@ -85,19 +81,18 @@ const VoiceWindow = translate()(
} else {
this.spectrum.stop()

// if simulation still active then start repeating the last audio
if (!this.props.readOnly) {
this.startRepeatingLastAudio()
this.initAnswerTimeout()
}
}
}

// Plays a single IVR prompt. Eventually calls `play()` to skip to the next
// audio or start waiting for an answer.
playIVR(ivr) {
// we may be interrupting an audio prompt here, so we stop any playing
// audio, before skipping to the next one:
this.audio.pause()
// we also stop any repeating question:
this.stopRepeatingLastAudio()

// play the IVR prompt, continuing to the next one when finished:
this.audio.src = audioURL(ivr)
Expand All @@ -109,10 +104,26 @@ const VoiceWindow = translate()(
this.spectrum.start(this.audio)
}

// Starts a timer that will call `noAnswer()`. Must be cancelled if a digit
// is pressed.
initAnswerTimeout(): void {
this.cancelAnswerTimeout()
this.answerTimeout = setTimeout(() => this.noAnswer(), 5000)
}

cancelAnswerTimeout(): void {
if (this.answerTimeout) {
clearTimeout(this.answerTimeout)
this.answerTimeout = null
}
}

// Appends the typed digit to the current message, then starts a 2s timer to
// send the message, unless another digit is pressed.
entered(character: string): void {
if (this.props.readOnly) return
if (this.messageTimer) clearTimeout(this.messageTimer)
this.stopRepeatingLastAudio()
this.cancelAnswerTimeout()
this.cancelMessageTimer()

this.message += character

Expand All @@ -124,51 +135,37 @@ const VoiceWindow = translate()(
}, 2000)
}

hangUp(): void {
this.audio.pause()

if (this.messageTimer) clearTimeout(this.messageTimer)
this.stopRepeatingLastAudio()

if (!this.props.readOnly) {
this.props.onSendMessage({ body: "stop", type: "at" })
cancelMessageTimer(): void {
if (this.messageTimer) {
clearTimeout(this.messageTimer)
this.messageTimer = null
}
}

startRepeatingLastAudio(): void {
if (!this.audio) return // no last audio available to repeat
if (this.repeatMessageTimer) return // already repeating audio

this.initRepeatTimer(() => {
if (this.timesRepeated < 3) {
this.clearRepeatTimer()
this.repeatLastAudio()
} else {
this.hangUp()
}
})
}

stopRepeatingLastAudio(): void {
this.clearRepeatTimer()
this.timesRepeated = 0
stopSimulation(): void {
this.audio.pause()
this.cancelAnswerTimeout()
this.cancelMessageTimer()
}

repeatLastAudio(): void {
if (!this.audio) return
// Simulates a phone hangup by sending a STOP message. This is kinda
// hackish, we could probably send a proper hangup message that would be
// properly handled by the simulator.
hangUp(): void {
this.stopSimulation()

this.timesRepeated += 1
this.spectrum.restart()
this.audio.play()
if (!this.props.readOnly) {
this.props.onSendMessage({ body: "hangup" })
}
}

initRepeatTimer(func): void {
this.repeatMessageTimer = setTimeout(func, 5000)
}
// Reports a timeout while waiting for an answer to the simulator.
noAnswer(): void {
this.stopSimulation()

clearRepeatTimer(): void {
if (this.repeatMessageTimer) clearTimeout(this.repeatMessageTimer)
this.repeatMessageTimer = null
if (!this.props.readOnly) {
this.props.onSendMessage({ body: "timeout" })
}
}

render() {
Expand Down
157 changes: 157 additions & 0 deletions lib/ask/runtime/questionnaire_ivr_simulator.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
defmodule Ask.QuestionnaireIvrSimulation do
defstruct [:respondent, :questionnaire, :section_order, messages: [], submissions: []]
end

defmodule Ask.QuestionnaireIvrSimulationStep do
defstruct [
:respondent_id,
:simulation_status,
:disposition,
:current_step,
:questionnaire,
messages_history: [],
prompts: [],
submissions: []
]

def start_build(simulation, current_step, status, messages_history, prompts) do
simulation_step =
Ask.QuestionnaireSimulationStep.build(simulation, current_step, status, true)

Map.merge(
%Ask.QuestionnaireIvrSimulationStep{messages_history: messages_history, prompts: prompts},
simulation_step
)
end

def sync_build(simulation, current_step, status, messages_history, prompts) do
simulation_step =
Ask.QuestionnaireSimulationStep.build(simulation, current_step, status, false)

Map.merge(
%Ask.QuestionnaireIvrSimulationStep{messages_history: messages_history, prompts: prompts},
simulation_step
)
end
end

defmodule Ask.Runtime.QuestionnaireIvrSimulator do
alias Ask.Runtime.{Survey, QuestionnaireSimulator, Flow}
alias Ask.{QuestionnaireIvrSimulation, QuestionnaireIvrSimulationStep}
alias Ask.Simulation.{AOMessage, ATMessage, IvrPrompt, Status, Response, SubmittedStep}

def start(project, questionnaire) do
start_build = fn respondent, reply ->
base_simulation = QuestionnaireSimulator.base_simulation(questionnaire, respondent)
messages = AOMessage.create_all(reply)
prompts = IvrPrompt.create_all(reply)

simulation =
Map.merge(base_simulation, %{
submissions: SubmittedStep.new_explanations(reply),
messages: messages
})

simulation = Map.merge(%QuestionnaireIvrSimulation{}, simulation)
current_step = QuestionnaireSimulator.current_step(reply)

response =
simulation
|> QuestionnaireIvrSimulationStep.start_build(
current_step,
Status.active(),
messages,
prompts
)
|> Response.success()

%{simulation: simulation, response: response}
end

delivery_confirm = fn respondent ->
# Simulating Verboice confirmation on message delivery
%{respondent: respondent} =
Survey.delivery_confirm(
QuestionnaireSimulator.sync_respondent(respondent),
"",
"ivr",
false
)

respondent
end

QuestionnaireSimulator.start(project, questionnaire, "ivr", start_build,
delivery_confirm: delivery_confirm
)
end

def process_respondent_response(respondent_id, response) do
build_simulation = fn simulation, _simulation_response ->
Map.merge(%QuestionnaireIvrSimulation{}, simulation)
end

handle_app_reply = fn simulation, respondent, reply, status ->
simulation = append_ao_message(simulation, reply)

sync_build = fn simulation, current_step, status, reply ->
prompts = IvrPrompt.create_all(reply)

QuestionnaireIvrSimulationStep.sync_build(
simulation,
current_step,
status,
simulation.messages,
prompts
)
end

QuestionnaireSimulator.handle_app_reply(simulation, respondent, reply, status, sync_build)
end

sync_simulation = fn simulation, response ->
if response == "hangup" do
QuestionnaireSimulator.stop_simulation(simulation, handle_app_reply)
else
{simulation, reply} = handle_at_message(simulation, response)
QuestionnaireSimulator.sync_simulation(simulation, reply, "ivr", handle_app_reply)
end
end

QuestionnaireSimulator.process_respondent_response(
respondent_id,
response,
build_simulation,
sync_simulation
)
end

defp append_ao_message(simulation, nil) do
simulation
end

defp append_ao_message(simulation, reply) do
messages = simulation.messages ++ AOMessage.create_all(reply)
Map.put(simulation, :messages, messages)
end

defp handle_at_message(simulation, "timeout") do
{simulation, Flow.Message.no_reply()}
end

defp handle_at_message(simulation, message) do
messages = simulation.messages ++ [ATMessage.new(message)]
simulation = Map.put(simulation, :messages, messages)
{simulation, Flow.Message.reply(message)}
end
end

defmodule Ask.Simulation.IvrPrompt do
def create_all(nil), do: []

def create_all(reply) do
Enum.map(Ask.Runtime.Reply.prompts(reply), fn prompt ->
prompt
end)
end
end
Loading

0 comments on commit 3ac99be

Please sign in to comment.