timgremore
commited on
Commit
•
34b4725
1
Parent(s):
29c5ebd
wip: feat: Separate transcriptions from liveview
Browse files- lib/medical_transcription/application.ex +5 -0
- lib/medical_transcription/transcription.ex +2 -47
- lib/medical_transcription/transcription_server.ex +83 -0
- lib/medical_transcription/transcription_supervisor.ex +18 -0
- lib/medical_transcription/utilities.ex +8 -0
- lib/medical_transcription_web/components/result_list_component.ex +32 -0
- lib/medical_transcription_web/components/transcription_text_component.ex +15 -22
- lib/medical_transcription_web/live/home_live/index.ex +28 -8
- test/medical_transcription/transcription_server_test.exs +31 -0
- test/medical_transcription/transcription_supervisor_test.exs +13 -0
- test/medical_transcription_web/live/home_live_test.exs +7 -1
lib/medical_transcription/application.ex
CHANGED
@@ -21,6 +21,11 @@ defmodule MedicalTranscription.Application do
|
|
21 |
transcription_spec(),
|
22 |
token_classification_spec(),
|
23 |
text_embedding_spec(),
|
|
|
|
|
|
|
|
|
|
|
24 |
# Start a worker by calling: MedicalTranscription.Worker.start_link(arg)
|
25 |
# {MedicalTranscription.Worker, arg},
|
26 |
# Start to serve requests, typically the last entry
|
|
|
21 |
transcription_spec(),
|
22 |
token_classification_spec(),
|
23 |
text_embedding_spec(),
|
24 |
+
{
|
25 |
+
MedicalTranscription.TranscriptionSupervisor,
|
26 |
+
name: MedicalTranscription.TranscriptionSupervisor,
|
27 |
+
strategy: :one_for_one
|
28 |
+
},
|
29 |
# Start a worker by calling: MedicalTranscription.Worker.start_link(arg)
|
30 |
# {MedicalTranscription.Worker, arg},
|
31 |
# Start to serve requests, typically the last entry
|
lib/medical_transcription/transcription.ex
CHANGED
@@ -3,52 +3,7 @@ defmodule MedicalTranscription.Transcription do
|
|
3 |
Takes a path to an audio file and transcribes it to text.
|
4 |
"""
|
5 |
|
6 |
-
|
7 |
-
|
8 |
-
# (such as pgvector or Pinecone.io)
|
9 |
-
# - A potential improvement would be to not code each chunk of transcribed audio separately, but to instead gather
|
10 |
-
# complete sentences based on punctuation. We may want to suggest codes for the entire audio as a single piece as
|
11 |
-
# well
|
12 |
-
def stream_transcription_and_search(live_view_pid, audio_file_path) do
|
13 |
-
# audio transcription + semantic search
|
14 |
-
summary_text =
|
15 |
-
audio_file_path
|
16 |
-
|> stream_transcription()
|
17 |
-
|> Enum.reduce("", fn {chunk, index}, acc ->
|
18 |
-
send_result(chunk, index + 1, live_view_pid)
|
19 |
-
|
20 |
-
acc <> chunk.text
|
21 |
-
end)
|
22 |
-
|
23 |
-
summary_chunk = %{
|
24 |
-
text: summary_text,
|
25 |
-
start_timestamp_seconds: nil,
|
26 |
-
end_timestamp_seconds: nil
|
27 |
-
}
|
28 |
-
|
29 |
-
send_result(summary_chunk, 0, live_view_pid)
|
30 |
-
end
|
31 |
-
|
32 |
-
defp stream_transcription(audio_file_path) do
|
33 |
-
MedicalTranscription.TranscriptionServing
|
34 |
-
|> Nx.Serving.batched_run({:file, audio_file_path})
|
35 |
-
|> Stream.with_index()
|
36 |
-
end
|
37 |
-
|
38 |
-
defp send_result(chunk, index, live_view_pid) do
|
39 |
-
result = %{
|
40 |
-
id: index,
|
41 |
-
start_mark: format_timestamp(chunk.start_timestamp_seconds),
|
42 |
-
end_mark: format_timestamp(chunk.end_timestamp_seconds),
|
43 |
-
text: chunk.text
|
44 |
-
}
|
45 |
-
|
46 |
-
send(live_view_pid, {:transcription_row, result})
|
47 |
-
end
|
48 |
-
|
49 |
-
defp format_timestamp(seconds) when is_nil(seconds), do: nil
|
50 |
-
|
51 |
-
defp format_timestamp(seconds) do
|
52 |
-
seconds |> round() |> Time.from_seconds_after_midnight() |> Time.to_string()
|
53 |
end
|
54 |
end
|
|
|
3 |
Takes a path to an audio file and transcribes it to text.
|
4 |
"""
|
5 |
|
6 |
+
def stream_transcription_and_search(audio_file_path) do
|
7 |
+
MedicalTranscription.TranscriptionSupervisor.start_transcription(audio_file_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
end
|
9 |
end
|
lib/medical_transcription/transcription_server.ex
ADDED
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
defmodule MedicalTranscription.TranscriptionServer do
|
2 |
+
@moduledoc """
|
3 |
+
GenServer responsible for transcribing audio files
|
4 |
+
"""
|
5 |
+
use GenServer
|
6 |
+
|
7 |
+
def start_link(args) do
|
8 |
+
GenServer.start_link(__MODULE__, args, [])
|
9 |
+
end
|
10 |
+
|
11 |
+
@impl GenServer
|
12 |
+
def init(init_arg) do
|
13 |
+
{:ok, init_arg, {:continue, :start}}
|
14 |
+
end
|
15 |
+
|
16 |
+
@impl GenServer
|
17 |
+
def handle_continue(:start, [file_path: file_path] = state) do
|
18 |
+
stream_transcription_and_search(file_path)
|
19 |
+
{:noreply, state}
|
20 |
+
end
|
21 |
+
|
22 |
+
@impl GenServer
|
23 |
+
def handle_info({:chunk, _result}, state) do
|
24 |
+
{:noreply, state}
|
25 |
+
end
|
26 |
+
|
27 |
+
def handle_info({:summary, result}, state) do
|
28 |
+
{:noreply, state}
|
29 |
+
end
|
30 |
+
|
31 |
+
def handle_info(:finished, state) do
|
32 |
+
{:stop, :shutdown, "Transcription finished"}
|
33 |
+
end
|
34 |
+
|
35 |
+
# Ideas for future exploration:
|
36 |
+
# - Instead of storing the long description vectors in a binary file on disk, we could store them within a vector DB
|
37 |
+
# (such as pgvector or Pinecone.io)
|
38 |
+
# - A potential improvement would be to not code each chunk of transcribed audio separately, but to instead gather
|
39 |
+
# complete sentences based on punctuation. We may want to suggest codes for the entire audio as a single piece as
|
40 |
+
# well
|
41 |
+
defp stream_transcription_and_search(audio_file_path) do
|
42 |
+
pid = self()
|
43 |
+
|
44 |
+
# audio transcription + semantic search
|
45 |
+
summary_text =
|
46 |
+
MedicalTranscription.TranscriptionServing
|
47 |
+
|> Nx.Serving.batched_run({:file, audio_file_path})
|
48 |
+
|> Stream.with_index()
|
49 |
+
|> Stream.map(fn {chunk, index} ->
|
50 |
+
send_result(:chunk, chunk, index + 1, pid)
|
51 |
+
chunk.text
|
52 |
+
end)
|
53 |
+
|> Enum.to_list()
|
54 |
+
|> Enum.join()
|
55 |
+
|
56 |
+
summary_chunk = %{
|
57 |
+
text: summary_text,
|
58 |
+
start_timestamp_seconds: nil,
|
59 |
+
end_timestamp_seconds: nil
|
60 |
+
}
|
61 |
+
|
62 |
+
send_result(:summary, summary_chunk, 0, pid)
|
63 |
+
|
64 |
+
send(pid, :finished)
|
65 |
+
end
|
66 |
+
|
67 |
+
defp send_result(status, chunk, index, pid) when status in [:chunk, :summary] do
|
68 |
+
result = %{
|
69 |
+
id: index,
|
70 |
+
start_mark: format_timestamp(chunk.start_timestamp_seconds),
|
71 |
+
end_mark: format_timestamp(chunk.end_timestamp_seconds),
|
72 |
+
text: chunk.text
|
73 |
+
}
|
74 |
+
|
75 |
+
send(pid, {status, result})
|
76 |
+
end
|
77 |
+
|
78 |
+
defp format_timestamp(seconds) when is_nil(seconds), do: nil
|
79 |
+
|
80 |
+
defp format_timestamp(seconds) do
|
81 |
+
seconds |> round() |> Time.from_seconds_after_midnight() |> Time.to_string()
|
82 |
+
end
|
83 |
+
end
|
lib/medical_transcription/transcription_supervisor.ex
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
defmodule MedicalTranscription.TranscriptionSupervisor do
|
2 |
+
# Automatically defines child_spec/1
|
3 |
+
use DynamicSupervisor, restart: :transient
|
4 |
+
|
5 |
+
def start_link(init_arg) do
|
6 |
+
DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
|
7 |
+
end
|
8 |
+
|
9 |
+
@impl true
|
10 |
+
def init(_init_arg) do
|
11 |
+
DynamicSupervisor.init(strategy: :one_for_one)
|
12 |
+
end
|
13 |
+
|
14 |
+
def start_transcription(uploaded_file) do
|
15 |
+
spec = {MedicalTranscription.TranscriptionServer, file_path: uploaded_file}
|
16 |
+
DynamicSupervisor.start_child(__MODULE__, spec)
|
17 |
+
end
|
18 |
+
end
|
lib/medical_transcription/utilities.ex
CHANGED
@@ -19,4 +19,12 @@ defmodule MedicalTranscription.Utilities do
|
|
19 |
tallied_enumerable
|
20 |
|> Enum.map_join(", ", fn {key, value} -> "#{key} (#{value})" end)
|
21 |
end
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
end
|
|
|
19 |
tallied_enumerable
|
20 |
|> Enum.map_join(", ", fn {key, value} -> "#{key} (#{value})" end)
|
21 |
end
|
22 |
+
|
23 |
+
def map_set_toggle(map_set, item) do
|
24 |
+
if Enum.any?(map_set, &(&1 == item)) do
|
25 |
+
Enum.reject(map_set, &(&1 == item))
|
26 |
+
else
|
27 |
+
map_set ++ [item]
|
28 |
+
end
|
29 |
+
end
|
30 |
end
|
lib/medical_transcription_web/components/result_list_component.ex
ADDED
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
defmodule MedicalTranscriptionWeb.Components.ResultListComponent do
|
2 |
+
@moduledoc """
|
3 |
+
Transcription chunk results list component.
|
4 |
+
"""
|
5 |
+
|
6 |
+
use MedicalTranscriptionWeb, :live_component
|
7 |
+
|
8 |
+
alias MedicalTranscriptionWeb.Components.TranscriptionTextComponent
|
9 |
+
|
10 |
+
@doc """
|
11 |
+
Shows a list of transcribed text from a stream, with actions for each.
|
12 |
+
"""
|
13 |
+
@impl Phoenix.LiveComponent
|
14 |
+
def render(assigns) do
|
15 |
+
dbg(assigns.finalized_codes)
|
16 |
+
|
17 |
+
~H"""
|
18 |
+
<div id="result_list" class="flex flex-col gap-14" phx-update="stream">
|
19 |
+
<%= for {dom_id, row} <- @rows do %>
|
20 |
+
<.live_component
|
21 |
+
module={TranscriptionTextComponent}
|
22 |
+
id={dom_id}
|
23 |
+
start_mark={row.start_mark}
|
24 |
+
end_mark={row.end_mark}
|
25 |
+
text={row.text}
|
26 |
+
finalized_codes={@finalized_codes}
|
27 |
+
/>
|
28 |
+
<% end %>
|
29 |
+
</div>
|
30 |
+
"""
|
31 |
+
end
|
32 |
+
end
|
lib/medical_transcription_web/components/transcription_text_component.ex
CHANGED
@@ -15,32 +15,21 @@ defmodule MedicalTranscriptionWeb.Components.TranscriptionTextComponent do
|
|
15 |
alias MedicalTranscription.Coding.CodeVectorMatch
|
16 |
|
17 |
@impl Phoenix.LiveComponent
|
18 |
-
def
|
19 |
-
|
20 |
|
21 |
-
|
22 |
-
end
|
23 |
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
|
30 |
-
|
31 |
-
id: id,
|
32 |
-
start_mark: start_mark,
|
33 |
-
end_mark: end_mark,
|
34 |
-
text: text
|
35 |
-
}
|
36 |
-
|
37 |
-
socket
|
38 |
-
|> assign(initial_state)
|
39 |
-
|> assign_async(:tags, fn -> classify_text(text) end)
|
40 |
-
|> assign_async(:keywords, fn -> find_keywords(self_pid, text) end)
|
41 |
-
end
|
42 |
|
43 |
-
|
|
|
44 |
|
45 |
@impl Phoenix.LiveComponent
|
46 |
def render(assigns) do
|
@@ -164,4 +153,8 @@ defmodule MedicalTranscriptionWeb.Components.TranscriptionTextComponent do
|
|
164 |
# 2. A fast process finding the phrase closest in vector space to the whole text.
|
165 |
KeywordFinder.find_most_similar_label(text, phrases, 2)
|
166 |
end
|
|
|
|
|
|
|
|
|
167 |
end
|
|
|
15 |
alias MedicalTranscription.Coding.CodeVectorMatch
|
16 |
|
17 |
@impl Phoenix.LiveComponent
|
18 |
+
def mount(socket) do
|
19 |
+
self_pid = self()
|
20 |
|
21 |
+
text = socket.assigns.text
|
|
|
22 |
|
23 |
+
socket =
|
24 |
+
socket
|
25 |
+
|> assign(socket.assigns)
|
26 |
+
|> assign_async(:tags, fn -> classify_text(text) end)
|
27 |
+
|> assign_async(:keywords, fn -> find_keywords(self_pid, text) end)
|
28 |
|
29 |
+
dbg(socket.assigns)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
30 |
|
31 |
+
{:ok, socket}
|
32 |
+
end
|
33 |
|
34 |
@impl Phoenix.LiveComponent
|
35 |
def render(assigns) do
|
|
|
153 |
# 2. A fast process finding the phrase closest in vector space to the whole text.
|
154 |
KeywordFinder.find_most_similar_label(text, phrases, 2)
|
155 |
end
|
156 |
+
|
157 |
+
defp code_selected?(code, finalized_codes) do
|
158 |
+
Enum.any?(finalized_codes, code)
|
159 |
+
end
|
160 |
end
|
lib/medical_transcription_web/live/home_live/index.ex
CHANGED
@@ -14,7 +14,8 @@ defmodule MedicalTranscriptionWeb.HomeLive.Index do
|
|
14 |
status: :pending,
|
15 |
audio_pipeline: nil,
|
16 |
summary_keywords: [],
|
17 |
-
transcriptions: list_transcriptions(session["current_user"])
|
|
|
18 |
}
|
19 |
|
20 |
socket =
|
@@ -39,14 +40,14 @@ defmodule MedicalTranscriptionWeb.HomeLive.Index do
|
|
39 |
<main class="flex-1 pl-16 pr-16 pt-[25px]">
|
40 |
<div class="flex flex-col h-full mx-auto max-w-5xl">
|
41 |
<div class="flex-1 flex flex-col space-y-6">
|
42 |
-
<.result_heading
|
43 |
-
status={@status}
|
44 |
-
filename={@uploaded_file_name}
|
45 |
-
summary_keywords={@summary_keywords}
|
46 |
-
/>
|
47 |
-
|
48 |
<%= if @status != :pending do %>
|
49 |
-
<.
|
|
|
|
|
|
|
|
|
|
|
|
|
50 |
<% end %>
|
51 |
|
52 |
<%= if @status == :pending do %>
|
@@ -184,6 +185,25 @@ defmodule MedicalTranscriptionWeb.HomeLive.Index do
|
|
184 |
{:noreply, socket}
|
185 |
end
|
186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
187 |
@impl true
|
188 |
def handle_info({ref, _result}, socket) do
|
189 |
# See this Fly article for the usage of Task.async to start `transcribe_and_tag_audio/2` and handle the end of the
|
|
|
14 |
status: :pending,
|
15 |
audio_pipeline: nil,
|
16 |
summary_keywords: [],
|
17 |
+
transcriptions: list_transcriptions(session["current_user"]),
|
18 |
+
finalized_codes: []
|
19 |
}
|
20 |
|
21 |
socket =
|
|
|
40 |
<main class="flex-1 pl-16 pr-16 pt-[25px]">
|
41 |
<div class="flex flex-col h-full mx-auto max-w-5xl">
|
42 |
<div class="flex-1 flex flex-col space-y-6">
|
|
|
|
|
|
|
|
|
|
|
|
|
43 |
<%= if @status != :pending do %>
|
44 |
+
<.live_component
|
45 |
+
module={MedicalTranscriptionWeb.Components.ResultListComponent}
|
46 |
+
id="result_list"
|
47 |
+
rows={@streams.transcription_rows}
|
48 |
+
summary_keywords={@summary_keywords}
|
49 |
+
finalized_codes={@finalized_codes}
|
50 |
+
/>
|
51 |
<% end %>
|
52 |
|
53 |
<%= if @status == :pending do %>
|
|
|
185 |
{:noreply, socket}
|
186 |
end
|
187 |
|
188 |
+
@impl Phoenix.LiveView
|
189 |
+
def handle_info({"toggle_user_selected_code", {:add, code}}, socket) do
|
190 |
+
new_codes =
|
191 |
+
if Enum.any?(socket.assigns.finalized_codes, code) do
|
192 |
+
socket.assigns.finalized_codes
|
193 |
+
else
|
194 |
+
socket.assigns.finalized_codes ++ [code]
|
195 |
+
end
|
196 |
+
|
197 |
+
{:noreply, assign(socket, :finalized_codes, new_codes)}
|
198 |
+
end
|
199 |
+
|
200 |
+
@impl Phoenix.LiveView
|
201 |
+
def handle_info({"toggle_user_selected_code", {:remove, code}}, socket) do
|
202 |
+
new_codes = Enum.reject(socket.assigns.finalized_codes, &(&1 == code))
|
203 |
+
|
204 |
+
{:noreply, assign(socket, :finalized_codes, new_codes)}
|
205 |
+
end
|
206 |
+
|
207 |
@impl true
|
208 |
def handle_info({ref, _result}, socket) do
|
209 |
# See this Fly article for the usage of Task.async to start `transcribe_and_tag_audio/2` and handle the end of the
|
test/medical_transcription/transcription_server_test.exs
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
defmodule MedicalTranscription.TranscriptionServerTest do
|
2 |
+
@moduledoc """
|
3 |
+
Tests for MedicalTranscription.TranscriptionServer
|
4 |
+
"""
|
5 |
+
|
6 |
+
use MedicalTranscription.DataCase
|
7 |
+
|
8 |
+
alias MedicalTranscription.TranscriptionServer
|
9 |
+
|
10 |
+
setup do
|
11 |
+
sample_file =
|
12 |
+
__DIR__
|
13 |
+
|> Path.join("../../medasrdemo-Paul.mp3")
|
14 |
+
|> Path.expand()
|
15 |
+
|
16 |
+
%{sample_file: sample_file}
|
17 |
+
end
|
18 |
+
|
19 |
+
test "transcribe and tag audio", %{sample_file: sample_file} do
|
20 |
+
spec = {TranscriptionServer, file_path: sample_file}
|
21 |
+
|
22 |
+
{:ok, pid} = start_supervised(spec)
|
23 |
+
|
24 |
+
# NOTE: Monitoring the async process to ensure it completes
|
25 |
+
# before asserting results. See https://elixirforum.com/t/whats-the-correct-way-to-handle-async-tasks-i-dont-care-about-in-an-exunit-test-error-postgrex-protocol-disconnected/36605/2
|
26 |
+
# for further explanation.
|
27 |
+
ref = Process.monitor(pid)
|
28 |
+
|
29 |
+
assert_receive({:DOWN, ^ref, :process, _object, _pid}, 5_000)
|
30 |
+
end
|
31 |
+
end
|
test/medical_transcription/transcription_supervisor_test.exs
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
defmodule MedicalTranscription.TranscriptionSupervisorTest do
|
2 |
+
@moduledoc """
|
3 |
+
Tests for MedicalTranscription.TranscriptionServer
|
4 |
+
"""
|
5 |
+
|
6 |
+
use MedicalTranscription.DataCase
|
7 |
+
|
8 |
+
alias MedicalTranscription.TranscriptionSupervisor
|
9 |
+
|
10 |
+
test "transcribe and tag audio" do
|
11 |
+
assert {:ok, _pid} = TranscriptionSupervisor.start_transcription("my-file.mp3")
|
12 |
+
end
|
13 |
+
end
|
test/medical_transcription_web/live/home_live_test.exs
CHANGED
@@ -53,11 +53,17 @@ defmodule MedicalTranscriptionWeb.HomeLiveTest do
|
|
53 |
|
54 |
assert view
|
55 |
|> form("#audio-form")
|
56 |
-
|> render_submit() =~ "
|
57 |
|
58 |
# TODO: Test that transcribed text appears
|
59 |
# TODO: Test that codes appear
|
60 |
# assert render_async(view, 5_000) =~ "Coronary artery anomaly"
|
61 |
end
|
|
|
|
|
|
|
|
|
|
|
|
|
62 |
end
|
63 |
end
|
|
|
53 |
|
54 |
assert view
|
55 |
|> form("#audio-form")
|
56 |
+
|> render_submit() =~ "Transcribing and tagging audio file..."
|
57 |
|
58 |
# TODO: Test that transcribed text appears
|
59 |
# TODO: Test that codes appear
|
60 |
# assert render_async(view, 5_000) =~ "Coronary artery anomaly"
|
61 |
end
|
62 |
+
|
63 |
+
test "renders transcription text", %{conn: conn} do
|
64 |
+
{:ok, view, html} = live(conn, "/")
|
65 |
+
html = view |> element("result_list")
|
66 |
+
assert html_response(conn, 200)
|
67 |
+
end
|
68 |
end
|
69 |
end
|