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
- |> assign(:transcription_rows, [])
10
- |> assign_async(:tagged_audio, fn -> {:ok, %{tagged_audio: []}} end)
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">&rarr;</span>
29
  </button>
30
 
31
- <%= for entry <- @uploads.audio.entries do %>
32
- <p>Submitted: <%= entry.client_name %></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
- |> assign_async(:tagged_audio, fn ->
73
- transcribe_and_tag_audio(live_view_pid, uploaded_file)
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
- socket.assigns.transcription_rows |> IO.inspect(label: "transcription_rows")
82
- chunk_result |> IO.inspect(label: "chunk_result")
 
 
83
 
84
- {:noreply, update(socket, :transcription_rows, &(&1 ++ [chunk_result]))}
 
 
 
 
 
85
  end
86
 
87
  def transcribe_and_tag_audio(live_view_pid, audio_file_path) do
88
- {model_info, tokenizer} = AudioTagger.Classifier.SemanticSearch.prepare_model()
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">&rarr;</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"