Merge pull request #11 from headwayio/feature/edit-transcription
Browse files
lib/medical_transcription/transcription.ex
CHANGED
@@ -1,18 +1,8 @@
|
|
1 |
defmodule MedicalTranscription.Transcription do
|
2 |
@moduledoc """
|
3 |
-
Takes a path to an audio file and transcribes it to text.
|
4 |
-
context to look for possible matching codes.
|
5 |
"""
|
6 |
|
7 |
-
alias MedicalTranscription.Coding
|
8 |
-
|
9 |
-
defp get_tags_and_send_result(chunk, index, live_view_pid) do
|
10 |
-
tags = Coding.process_chunk(chunk.text)
|
11 |
-
result = build_result(index, chunk, tags)
|
12 |
-
|
13 |
-
send(live_view_pid, {:transcription_row, result})
|
14 |
-
end
|
15 |
-
|
16 |
# Ideas for future exploration:
|
17 |
# - Instead of storing the long description vectors in a binary file on disk, we could store them within a vector DB
|
18 |
# (such as pgvector or Pinecone.io)
|
@@ -25,7 +15,7 @@ defmodule MedicalTranscription.Transcription do
|
|
25 |
audio_file_path
|
26 |
|> stream_transcription()
|
27 |
|> Enum.reduce("", fn {chunk, index}, acc ->
|
28 |
-
|
29 |
|
30 |
acc <> chunk.text
|
31 |
end)
|
@@ -36,7 +26,7 @@ defmodule MedicalTranscription.Transcription do
|
|
36 |
end_timestamp_seconds: nil
|
37 |
}
|
38 |
|
39 |
-
|
40 |
end
|
41 |
|
42 |
defp stream_transcription(audio_file_path) do
|
@@ -45,14 +35,15 @@ defmodule MedicalTranscription.Transcription do
|
|
45 |
|> Stream.with_index()
|
46 |
end
|
47 |
|
48 |
-
defp
|
49 |
-
%{
|
50 |
id: index,
|
51 |
start_mark: format_timestamp(chunk.start_timestamp_seconds),
|
52 |
end_mark: format_timestamp(chunk.end_timestamp_seconds),
|
53 |
-
text: chunk.text
|
54 |
-
tags: tags
|
55 |
}
|
|
|
|
|
56 |
end
|
57 |
|
58 |
defp format_timestamp(seconds) when is_nil(seconds), do: nil
|
|
|
1 |
defmodule MedicalTranscription.Transcription do
|
2 |
@moduledoc """
|
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)
|
|
|
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)
|
|
|
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
|
|
|
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
|
lib/medical_transcription_web/components/components.ex
CHANGED
@@ -143,7 +143,13 @@ defmodule MedicalTranscriptionWeb.Components do
|
|
143 |
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
|
144 |
>
|
145 |
<%= for {dom_id, row} <- @rows do %>
|
146 |
-
<.live_component
|
|
|
|
|
|
|
|
|
|
|
|
|
147 |
<% end %>
|
148 |
</div>
|
149 |
"""
|
|
|
143 |
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
|
144 |
>
|
145 |
<%= for {dom_id, row} <- @rows do %>
|
146 |
+
<.live_component
|
147 |
+
module={TranscriptionTextComponent}
|
148 |
+
id={dom_id}
|
149 |
+
start_mark={row.start_mark}
|
150 |
+
end_mark={row.end_mark}
|
151 |
+
text={row.text}
|
152 |
+
/>
|
153 |
<% end %>
|
154 |
</div>
|
155 |
"""
|
lib/medical_transcription_web/components/layouts/app.html.heex
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
<div class="min-h-[calc(100vh-56px)] flex gap-5">
|
2 |
-
<header class="w-[335px] min-w-[335px] pt-6 border-r border-gray-200 flex flex-col gap-12">
|
3 |
<div class="flex items-center gap-[10.5px]">
|
4 |
<img src={~p"/images/logo.svg"} width="48" />
|
5 |
|
|
|
1 |
<div class="min-h-[calc(100vh-56px)] flex gap-5">
|
2 |
+
<header class="hidden w-[335px] min-w-[335px] pt-6 border-r border-gray-200 lg:flex flex-col gap-12">
|
3 |
<div class="flex items-center gap-[10.5px]">
|
4 |
<img src={~p"/images/logo.svg"} width="48" />
|
5 |
|
lib/medical_transcription_web/components/transcription_text_component.ex
CHANGED
@@ -1,30 +1,42 @@
|
|
1 |
defmodule MedicalTranscriptionWeb.Components.TranscriptionTextComponent do
|
2 |
@moduledoc """
|
3 |
-
Represents a portion of transcribed text
|
4 |
-
"""
|
5 |
|
|
|
|
|
6 |
use MedicalTranscriptionWeb, :live_component
|
|
|
7 |
import MedicalTranscriptionWeb.Components
|
8 |
import MedicalTranscriptionWeb.Components.KeywordHighlighter
|
|
|
9 |
alias AudioTagger.KeywordFinder
|
|
|
10 |
alias MedicalTranscription.Coding.CodeVectorMatch
|
11 |
|
12 |
@impl Phoenix.LiveComponent
|
13 |
def update(assigns, socket) do
|
14 |
-
socket =
|
15 |
|
16 |
{:ok, socket}
|
17 |
end
|
18 |
|
19 |
-
defp
|
20 |
self_pid = self()
|
21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
socket
|
23 |
-
|> assign(
|
24 |
-
|> assign_async(:
|
|
|
25 |
end
|
26 |
|
27 |
-
defp
|
28 |
|
29 |
@impl Phoenix.LiveComponent
|
30 |
def render(assigns) do
|
@@ -32,14 +44,20 @@ defmodule MedicalTranscriptionWeb.Components.TranscriptionTextComponent do
|
|
32 |
<div class="flex gap-12 pb-10 border-b border-[#444444]/20">
|
33 |
<div class="flex-1 flex flex-col gap-4">
|
34 |
<p class="text-[32px] leading-normal font-semibold">
|
35 |
-
<%= if !is_nil(@
|
36 |
-
<%= @
|
37 |
<% end %>
|
38 |
</p>
|
39 |
<p class="text-[28px] leading-normal text-type-black-tertiary">
|
40 |
<.async_result :let={keywords} assign={@keywords}>
|
41 |
-
<:loading><%= @
|
42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
43 |
</.async_result>
|
44 |
</p>
|
45 |
|
@@ -55,21 +73,50 @@ defmodule MedicalTranscriptionWeb.Components.TranscriptionTextComponent do
|
|
55 |
</div>
|
56 |
|
57 |
<div class="flex-1 flex flex-col items-stretch gap-3">
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
68 |
</div>
|
69 |
</div>
|
70 |
"""
|
71 |
end
|
72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
73 |
defp find_keywords(_live_view_pid, ""), do: {:ok, %{keywords: []}}
|
74 |
|
75 |
defp find_keywords(live_view_pid, text) do
|
|
|
1 |
defmodule MedicalTranscriptionWeb.Components.TranscriptionTextComponent do
|
2 |
@moduledoc """
|
3 |
+
Represents a portion of transcribed text.
|
|
|
4 |
|
5 |
+
Once rendered, starts tasks to find relevant codes and keywords for the text.
|
6 |
+
"""
|
7 |
use MedicalTranscriptionWeb, :live_component
|
8 |
+
|
9 |
import MedicalTranscriptionWeb.Components
|
10 |
import MedicalTranscriptionWeb.Components.KeywordHighlighter
|
11 |
+
|
12 |
alias AudioTagger.KeywordFinder
|
13 |
+
alias MedicalTranscription.Coding
|
14 |
alias MedicalTranscription.Coding.CodeVectorMatch
|
15 |
|
16 |
@impl Phoenix.LiveComponent
|
17 |
def update(assigns, socket) do
|
18 |
+
socket = assign_initial_state(assigns, socket)
|
19 |
|
20 |
{:ok, socket}
|
21 |
end
|
22 |
|
23 |
+
defp assign_initial_state(%{start_mark: start_mark, end_mark: end_mark, text: text}, socket) do
|
24 |
self_pid = self()
|
25 |
|
26 |
+
initial_state = %{
|
27 |
+
start_mark: start_mark,
|
28 |
+
end_mark: end_mark,
|
29 |
+
text: text,
|
30 |
+
editing: false
|
31 |
+
}
|
32 |
+
|
33 |
socket
|
34 |
+
|> assign(initial_state)
|
35 |
+
|> assign_async(:tags, fn -> classify_text(text) end)
|
36 |
+
|> assign_async(:keywords, fn -> find_keywords(self_pid, text) end)
|
37 |
end
|
38 |
|
39 |
+
defp assign_initial_state(_assigns, socket), do: socket
|
40 |
|
41 |
@impl Phoenix.LiveComponent
|
42 |
def render(assigns) do
|
|
|
44 |
<div class="flex gap-12 pb-10 border-b border-[#444444]/20">
|
45 |
<div class="flex-1 flex flex-col gap-4">
|
46 |
<p class="text-[32px] leading-normal font-semibold">
|
47 |
+
<%= if !is_nil(@start_mark) && !is_nil(@end_mark) do %>
|
48 |
+
<%= @start_mark %> - <%= @end_mark %>
|
49 |
<% end %>
|
50 |
</p>
|
51 |
<p class="text-[28px] leading-normal text-type-black-tertiary">
|
52 |
<.async_result :let={keywords} assign={@keywords}>
|
53 |
+
<:loading><%= @text %></:loading>
|
54 |
+
<%= if @editing do %>
|
55 |
+
<textarea class="w-full" phx-blur="reclassify_transcription" phx-target={@myself}><%= @text %></textarea>
|
56 |
+
<% else %>
|
57 |
+
<div class="text-[28px]" phx-click="edit_transcription" phx-target={@myself}>
|
58 |
+
<.highlight text={@text} keywords={keywords} />
|
59 |
+
</div>
|
60 |
+
<% end %>
|
61 |
</.async_result>
|
62 |
</p>
|
63 |
|
|
|
73 |
</div>
|
74 |
|
75 |
<div class="flex-1 flex flex-col items-stretch gap-3">
|
76 |
+
<.async_result :let={tags} assign={@tags}>
|
77 |
+
<:loading>
|
78 |
+
<img src={~p"/images/loading.svg"} width="16" class="animate-spin" /> Finding codes...
|
79 |
+
</:loading>
|
80 |
+
<:failed :let={reason}>There was an error finding codes: <%= reason %></:failed>
|
81 |
+
<%= for %CodeVectorMatch{id: id, code: code, description: label, cosine_similarity: score, weighting: weighting} <- tags do %>
|
82 |
+
<.tag_result
|
83 |
+
code_vector_id={id}
|
84 |
+
code={code}
|
85 |
+
label={label}
|
86 |
+
score={score}
|
87 |
+
text={@text}
|
88 |
+
weighting={weighting}
|
89 |
+
/>
|
90 |
+
<% end %>
|
91 |
+
</.async_result>
|
92 |
</div>
|
93 |
</div>
|
94 |
"""
|
95 |
end
|
96 |
|
97 |
+
@impl Phoenix.LiveComponent
|
98 |
+
def handle_event("edit_transcription", _params, socket) do
|
99 |
+
{:noreply, assign(socket, :editing, true)}
|
100 |
+
end
|
101 |
+
|
102 |
+
@impl Phoenix.LiveComponent
|
103 |
+
def handle_event("reclassify_transcription", %{"value" => value} = _params, socket) do
|
104 |
+
self_pid = self()
|
105 |
+
|
106 |
+
socket =
|
107 |
+
socket
|
108 |
+
|> assign(:text, value)
|
109 |
+
|> assign(:editing, false)
|
110 |
+
|> assign_async(:tags, fn -> classify_text(value) end)
|
111 |
+
|> assign_async(:keywords, fn -> find_keywords(self_pid, value) end)
|
112 |
+
|
113 |
+
{:noreply, socket}
|
114 |
+
end
|
115 |
+
|
116 |
+
def classify_text(value) do
|
117 |
+
{:ok, %{tags: Coding.process_chunk(value)}}
|
118 |
+
end
|
119 |
+
|
120 |
defp find_keywords(_live_view_pid, ""), do: {:ok, %{keywords: []}}
|
121 |
|
122 |
defp find_keywords(live_view_pid, text) do
|