noahsettersten
commited on
Commit
•
bdffcf8
1
Parent(s):
63bdb9a
feat: Use LiveView streams; extract ML work; show in-progress
Browse files- Move ML processing into new MedicalTranscription.Transcriber module.
- Add updates to a LiveView stream.
- Show a message when the processing is in progress.
lib/medical_transcription/transcriber.ex
ADDED
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
defmodule MedicalTranscription.Transcriber do
|
2 |
+
def test_stream(live_view_pid) do
|
3 |
+
# Stream test
|
4 |
+
1..5
|
5 |
+
|> Stream.with_index()
|
6 |
+
|> Stream.each(fn {element, index} ->
|
7 |
+
Process.sleep(1_000)
|
8 |
+
|
9 |
+
send(
|
10 |
+
live_view_pid,
|
11 |
+
{:transcription_row,
|
12 |
+
%{id: index, start_mark: 1, end_mark: 2, text: "Hello, world", tags: "ABCD"}}
|
13 |
+
)
|
14 |
+
end)
|
15 |
+
|> Stream.run()
|
16 |
+
end
|
17 |
+
|
18 |
+
def stream_transcription(live_view_pid, audio_file_path) do
|
19 |
+
# Audio transcription only
|
20 |
+
for chunk <- Nx.Serving.batched_run(TranscriptionServing, {:file, audio_file_path}) do
|
21 |
+
chunk_result = %{
|
22 |
+
# id: index,
|
23 |
+
start_mark: chunk.start_timestamp_seconds,
|
24 |
+
end_mark: chunk.end_timestamp_seconds,
|
25 |
+
text: chunk.text,
|
26 |
+
tags: ""
|
27 |
+
}
|
28 |
+
|> dbg()
|
29 |
+
|
30 |
+
send(live_view_pid, {:transcription_row, chunk_result})
|
31 |
+
end
|
32 |
+
end
|
33 |
+
|
34 |
+
def stream_transcription_and_search(live_view_pid, audio_file_path) do
|
35 |
+
{model_info, tokenizer} = AudioTagger.Classifier.SemanticSearch.prepare_model()
|
36 |
+
|
37 |
+
labels_df = AudioTagger.SampleData.icd10_codes()
|
38 |
+
label_embeddings_path = Path.join(__DIR__, "../../icd10_vector_tensors.bin")
|
39 |
+
|
40 |
+
label_embeddings =
|
41 |
+
AudioTagger.Classifier.SemanticSearch.load_label_vectors(label_embeddings_path)
|
42 |
+
|
43 |
+
# Audio transcription + semantic search
|
44 |
+
for {chunk, index} <-
|
45 |
+
TranscriptionServing
|
46 |
+
|> Nx.Serving.batched_run({:file, audio_file_path})
|
47 |
+
|> Stream.with_index() do
|
48 |
+
# TODO: A potential improvement would be to not code each chunk of transcribed audio, but to instead gather
|
49 |
+
# complete sentences based on punctuation.
|
50 |
+
tags =
|
51 |
+
AudioTagger.Classifier.SemanticSearch.tag_one(
|
52 |
+
{model_info, tokenizer},
|
53 |
+
labels_df,
|
54 |
+
label_embeddings,
|
55 |
+
chunk.text
|
56 |
+
)
|
57 |
+
|
58 |
+
[start_mark, end_mark] =
|
59 |
+
for seconds <- [chunk.start_timestamp_seconds, chunk.end_timestamp_seconds] do
|
60 |
+
seconds |> round() |> Time.from_seconds_after_midnight() |> Time.to_string()
|
61 |
+
end
|
62 |
+
|
63 |
+
chunk_result = %{
|
64 |
+
id: index,
|
65 |
+
start_mark: start_mark,
|
66 |
+
end_mark: end_mark,
|
67 |
+
text: chunk.text,
|
68 |
+
tags: tags
|
69 |
+
}
|
70 |
+
|
71 |
+
send(live_view_pid, {:transcription_row, chunk_result})
|
72 |
+
end
|
73 |
+
|
74 |
+
# |> Stream.run()
|
75 |
+
|
76 |
+
# {:ok, %{tagged_audio: result}}
|
77 |
+
end
|
78 |
+
end
|
lib/medical_transcription_web/live/home_live/index.ex
CHANGED
@@ -6,13 +6,20 @@ defmodule MedicalTranscriptionWeb.HomeLive.Index do
|
|
6 |
{:ok,
|
7 |
socket
|
8 |
|> assign(:uploaded_files, [])
|
9 |
-
|>
|
10 |
-
|>
|
11 |
|> allow_upload(:audio, accept: ~w(.mp3), max_entries: 1)}
|
12 |
end
|
13 |
|
14 |
@impl Phoenix.LiveView
|
15 |
def render(assigns) do
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
16 |
~H"""
|
17 |
<form id="audio-form" phx-submit="save" phx-change="validate">
|
18 |
<div class="flex flex-col space-y-4">
|
@@ -28,15 +35,18 @@ defmodule MedicalTranscriptionWeb.HomeLive.Index do
|
|
28 |
Transcribe and Tag Audio <span aria-hidden="true">→</span>
|
29 |
</button>
|
30 |
|
31 |
-
<%=
|
32 |
-
<p
|
|
|
|
|
|
|
33 |
<% end %>
|
34 |
|
35 |
-
<.table id="streamed_result" rows={@transcription_rows}>
|
36 |
-
<:col :let={row} label="Start"><%= row.start_mark %></:col>
|
37 |
-
<:col :let={row} label="End"><%= row.end_mark %></:col>
|
38 |
-
<:col :let={row} label="Text"><%= row.text %></:col>
|
39 |
-
<:col :let={row} label="Codes"><%= row.tags %></:col>
|
40 |
</.table>
|
41 |
</div>
|
42 |
</form>
|
@@ -59,66 +69,37 @@ defmodule MedicalTranscriptionWeb.HomeLive.Index do
|
|
59 |
|
60 |
# Task async -> Audio Tagger -> get transcribed audio
|
61 |
|
62 |
-
# Ideas:
|
63 |
-
# 1 - Stream output
|
64 |
-
# 2 - More intelligent code matching (e.g. return more than one if close, filter out low thresholds)
|
65 |
-
|
66 |
uploaded_file = Enum.at(uploaded_files, 0)
|
67 |
-
|
68 |
live_view_pid = self()
|
69 |
|
|
|
|
|
70 |
socket =
|
71 |
socket
|
72 |
-
|>
|
73 |
-
|
74 |
-
end)
|
75 |
|> update(:uploaded_files, &(&1 ++ uploaded_files))
|
76 |
|
77 |
{:noreply, socket}
|
78 |
end
|
79 |
|
|
|
80 |
def handle_info({:transcription_row, chunk_result}, socket) do
|
81 |
-
|
82 |
-
|
|
|
|
|
83 |
|
84 |
-
|
|
|
|
|
|
|
|
|
|
|
85 |
end
|
86 |
|
87 |
def transcribe_and_tag_audio(live_view_pid, audio_file_path) do
|
88 |
-
|
89 |
-
|
90 |
-
labels_df = AudioTagger.SampleData.icd10_codes()
|
91 |
-
label_embeddings_path = Path.join(__DIR__, "../../../../icd10_vector_tensors.bin")
|
92 |
-
|
93 |
-
label_embeddings =
|
94 |
-
AudioTagger.Classifier.SemanticSearch.load_label_vectors(label_embeddings_path)
|
95 |
-
|
96 |
-
result =
|
97 |
-
TranscriptionServing
|
98 |
-
|> Nx.Serving.batched_run({:file, audio_file_path})
|
99 |
-
|> Enum.map(fn chunk ->
|
100 |
-
tags =
|
101 |
-
AudioTagger.Classifier.SemanticSearch.tag_one(
|
102 |
-
{model_info, tokenizer},
|
103 |
-
labels_df,
|
104 |
-
label_embeddings,
|
105 |
-
chunk.text
|
106 |
-
)
|
107 |
-
|
108 |
-
[start_mark, end_mark] =
|
109 |
-
for seconds <- [chunk.start_timestamp_seconds, chunk.end_timestamp_seconds] do
|
110 |
-
seconds |> round() |> Time.from_seconds_after_midnight() |> Time.to_string()
|
111 |
-
end
|
112 |
-
|
113 |
-
chunk_result = %{start_mark: start_mark, end_mark: end_mark, text: chunk.text, tags: tags}
|
114 |
-
send(live_view_pid, {:transcription_row, chunk_result})
|
115 |
-
|
116 |
-
chunk_result
|
117 |
-
end)
|
118 |
-
# |> Enum.map(&Function.identity/1)
|
119 |
-
# |> dbg()
|
120 |
-
|
121 |
-
{:ok, %{tagged_audio: result}}
|
122 |
end
|
123 |
|
124 |
def error_to_string(:too_large), do: "Too large"
|
|
|
6 |
{:ok,
|
7 |
socket
|
8 |
|> assign(:uploaded_files, [])
|
9 |
+
|> stream(:transcription_rows, [])
|
10 |
+
|> assign(:transcription_in_progress, false)
|
11 |
|> allow_upload(:audio, accept: ~w(.mp3), max_entries: 1)}
|
12 |
end
|
13 |
|
14 |
@impl Phoenix.LiveView
|
15 |
def render(assigns) do
|
16 |
+
# TODO: Show a loading state while returning results from the audio transcription & tagging.
|
17 |
+
# TODO: Stream audio recording instead of uploaded audio.
|
18 |
+
# TODO: Allow editing the transcription inline to correct mistakes. Then, retag based on the updated transcription.
|
19 |
+
# TODO: Show multiple codes.
|
20 |
+
# TODO: Allow users to accept/decline suggested codes.
|
21 |
+
# TODO: Train model based on user feedback for suggested codes.
|
22 |
+
|
23 |
~H"""
|
24 |
<form id="audio-form" phx-submit="save" phx-change="validate">
|
25 |
<div class="flex flex-col space-y-4">
|
|
|
35 |
Transcribe and Tag Audio <span aria-hidden="true">→</span>
|
36 |
</button>
|
37 |
|
38 |
+
<%= if @transcription_in_progress do %>
|
39 |
+
<div class="flex gap-2 items-center p-2 rounded-md text-slate-800 text-sm bg-slate-200 border border-slate-300">
|
40 |
+
<.icon name="hero-arrow-path" class="w-4 h-4 animate-spin" />
|
41 |
+
<p>Transcribing and tagging audio file...</p>
|
42 |
+
</div>
|
43 |
<% end %>
|
44 |
|
45 |
+
<.table id="streamed_result" rows={@streams.transcription_rows}>
|
46 |
+
<:col :let={row} label="Start"><%= elem(row, 1).start_mark %></:col>
|
47 |
+
<:col :let={row} label="End"><%= elem(row, 1).end_mark %></:col>
|
48 |
+
<:col :let={row} label="Text"><%= elem(row, 1).text %></:col>
|
49 |
+
<:col :let={row} label="Codes"><%= elem(row, 1).tags %></:col>
|
50 |
</.table>
|
51 |
</div>
|
52 |
</form>
|
|
|
69 |
|
70 |
# Task async -> Audio Tagger -> get transcribed audio
|
71 |
|
|
|
|
|
|
|
|
|
72 |
uploaded_file = Enum.at(uploaded_files, 0)
|
|
|
73 |
live_view_pid = self()
|
74 |
|
75 |
+
Task.async(fn -> transcribe_and_tag_audio(live_view_pid, uploaded_file) end)
|
76 |
+
|
77 |
socket =
|
78 |
socket
|
79 |
+
|> assign(:transcription_in_progress, true)
|
80 |
+
|> assign(:transcription_rows, [])
|
|
|
81 |
|> update(:uploaded_files, &(&1 ++ uploaded_files))
|
82 |
|
83 |
{:noreply, socket}
|
84 |
end
|
85 |
|
86 |
+
@impl true
|
87 |
def handle_info({:transcription_row, chunk_result}, socket) do
|
88 |
+
# The processing sends a message as each chunk of text is coded. See here for some background and potential
|
89 |
+
# inspiration for this: https://elixirforum.com/t/liveview-asynchronous-task-patterns/44695
|
90 |
+
{:noreply, stream_insert(socket, :transcription_rows, chunk_result)}
|
91 |
+
end
|
92 |
|
93 |
+
@impl true
|
94 |
+
def handle_info({ref, result}, socket) do
|
95 |
+
# See this Fly article for the usage of Task.async to start `transcribe_and_tag_audio/2` and handle the end of the
|
96 |
+
# task here: https://fly.io/phoenix-files/liveview-async-task/
|
97 |
+
Process.demonitor(ref, [:flush])
|
98 |
+
{:noreply, assign(socket, :transcription_in_progress, false)}
|
99 |
end
|
100 |
|
101 |
def transcribe_and_tag_audio(live_view_pid, audio_file_path) do
|
102 |
+
MedicalTranscription.Transcriber.stream_transcription_and_search(live_view_pid, audio_file_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
103 |
end
|
104 |
|
105 |
def error_to_string(:too_large), do: "Too large"
|