timgremore commited on
Commit
34b4725
1 Parent(s): 29c5ebd

wip: feat: Separate transcriptions from liveview

Browse files
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
- # Ideas for future exploration:
7
- # - Instead of storing the long description vectors in a binary file on disk, we could store them within a vector DB
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 update(assigns, socket) do
19
- socket = assign_initial_state(assigns, socket)
20
 
21
- {:ok, socket}
22
- end
23
 
24
- defp assign_initial_state(
25
- %{id: id, start_mark: start_mark, end_mark: end_mark, text: text},
26
- socket
27
- ) do
28
- self_pid = self()
29
 
30
- initial_state = %{
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
- defp assign_initial_state(_assigns, socket), do: socket
 
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
- <.result_list rows={@streams.transcription_rows} />
 
 
 
 
 
 
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() =~ "Summary Keywords"
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