Skip to content

Commit 5f9a208

Browse files
authored
[HOS-695] text annotation app (#94)
* text annotation app * preview image * hmm * ruff * fix deprecation error * fix text spacing --------- Co-authored-by: pourhakimi <84860195+pourhakimi@users.noreply.github.com>
1 parent 1875176 commit 5f9a208

File tree

13 files changed

+609
-0
lines changed

13 files changed

+609
-0
lines changed

.github/workflows/deploy.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ jobs:
3535
ai_image_gen)
3636
echo "EXTRA_ARGS=--env REPLICATE_API_TOKEN=${{ secrets.REPLICATE_API_TOKEN }}" >> $GITHUB_ENV
3737
;;
38+
text_annotation_app)
39+
echo "EXTRA_ARGS=" >> $GITHUB_ENV
40+
;;
3841
chat_app)
3942
echo "EXTRA_ARGS=" >> $GITHUB_ENV
4043
;;

templates.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@
158158
"demo_url": "https://chat-app-gold-book.reflex.run/",
159159
"hidden": false,
160160
"reflex_build": true
161+
},
162+
{
163+
"name": "text_annotation_app",
164+
"description": "A text annotation app",
165+
"demo_url": "https://company-dashboard-navy-book.reflex.run/",
166+
"hidden": false,
167+
"reflex_build": true
161168
}
162169
]
163170
}

text_annotation_app/.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.states
2+
*.db
3+
*.py[cod]
4+
.web
5+
__pycache__
6+
__pycache__/
7+
assets/external/
8+
.DS_Store
9+
.idea/
4.19 KB
Binary file not shown.

text_annotation_app/preview.png

490 KB
Loading
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
reflex>=0.7.8

text_annotation_app/rxconfig.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import reflex as rx
2+
3+
config = rx.Config(app_name="text_annotation_app")

text_annotation_app/text_annotation_app/__init__.py

Whitespace-only changes.

text_annotation_app/text_annotation_app/components/__init__.py

Whitespace-only changes.
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
from typing import Tuple
2+
3+
import reflex as rx
4+
5+
from text_annotation_app.states.ner_state import (
6+
EntityInfo,
7+
NerState,
8+
Segment,
9+
)
10+
11+
12+
def entity_button(entity: EntityInfo) -> rx.Component:
13+
"""Creates a button for an entity type with a remove option."""
14+
is_selected = NerState.selected_label_name == entity["name"]
15+
base_classes = f"px-3 py-1 {entity['color']} {entity['text_color']} rounded-md text-sm flex items-center transition-all duration-150 ease-in-out"
16+
selected_class_str = f"{base_classes} scale-105 shadow-md border-2 border-black"
17+
unselected_class_str = (
18+
f"{base_classes} hover:scale-105 shadow-sm border-2 border-transparent"
19+
)
20+
return rx.el.div(
21+
rx.el.span(
22+
entity["name"],
23+
class_name="font-semibold mr-2 cursor-pointer flex-grow",
24+
on_click=lambda: NerState.select_label(entity["name"]),
25+
),
26+
rx.el.button(
27+
"X",
28+
on_click=lambda: NerState.remove_label(entity["name"]),
29+
class_name="ml-1 text-xs font-bold text-black/50 hover:text-black p-0.5 rounded-full w-4 h-4 flex items-center justify-center leading-none bg-white/40 hover:bg-white/70 transition-colors flex-shrink-0",
30+
title=f"Remove {entity['name']} label",
31+
),
32+
class_name=rx.cond(
33+
is_selected,
34+
selected_class_str,
35+
unselected_class_str,
36+
),
37+
)
38+
39+
40+
def color_swatch(color_pair: rx.Var[Tuple[str, str]], index: int) -> rx.Component:
41+
"""Creates a clickable color swatch.
42+
43+
Args:
44+
color_pair: A Var representing the tuple (bg_color, text_color).
45+
index: The index of this color pair in the available_colors list.
46+
47+
Returns:
48+
A component representing the color swatch.
49+
"""
50+
bg_color = color_pair[0]
51+
is_selected = NerState.new_label_selected_color_index == index
52+
base_class = "w-6 h-6 rounded cursor-pointer transition-all duration-150 ease-in-out border-2"
53+
selected_class = f"{base_class} border-black scale-110"
54+
unselected_class = f"{base_class} border-transparent hover:scale-105"
55+
return rx.el.div(
56+
class_name=rx.cond(
57+
is_selected,
58+
f"{selected_class} {bg_color}",
59+
f"{unselected_class} {bg_color}",
60+
),
61+
on_click=lambda: NerState.set_new_label_color_index(index),
62+
title=f"Select {bg_color}",
63+
)
64+
65+
66+
def add_label_form() -> rx.Component:
67+
"""Form to add new labels, including color selection."""
68+
return rx.el.div(
69+
rx.el.h3(
70+
"Add New Label",
71+
class_name="text-md font-semibold mb-3 text-gray-700",
72+
),
73+
rx.el.div(
74+
rx.el.input(
75+
placeholder="Label Name (e.g., DATE)",
76+
on_change=NerState.set_new_label_name,
77+
class_name="border border-gray-300 rounded px-2 py-1 mr-2 flex-grow focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500",
78+
default_value=NerState.new_label_name,
79+
),
80+
rx.el.input(
81+
placeholder="Keywords (comma-separated, optional)",
82+
on_change=NerState.set_new_label_keywords_str,
83+
class_name="border border-gray-300 rounded px-2 py-1 mr-2 flex-grow-[2] focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500",
84+
default_value=NerState.new_label_keywords_str,
85+
),
86+
class_name="flex items-center gap-2 mb-3",
87+
),
88+
rx.el.div(
89+
rx.el.label(
90+
"Select Color:",
91+
class_name="text-sm font-medium text-gray-600 mr-2 self-center",
92+
),
93+
rx.el.div(
94+
rx.foreach(
95+
NerState.available_colors,
96+
lambda color, index: color_swatch(color, index),
97+
),
98+
class_name="flex flex-wrap gap-2 items-center flex-grow",
99+
),
100+
rx.el.button(
101+
"Add Label",
102+
on_click=NerState.add_new_label,
103+
class_name="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-1 rounded transition-colors shadow-sm ml-4 self-center",
104+
),
105+
class_name="flex items-center gap-2",
106+
),
107+
class_name="p-4 bg-gray-200 border-t border-gray-300",
108+
)
109+
110+
111+
def header_bar() -> rx.Component:
112+
"""Creates the header bar with entity buttons, add form, and download button."""
113+
return rx.el.div(
114+
rx.el.div(
115+
rx.el.div(
116+
rx.el.p(
117+
"Click a label below to select it, then click words in the text to apply/remove the label.",
118+
class_name=rx.cond(
119+
NerState.selected_label_name is None,
120+
"text-sm text-gray-600 mb-2 px-4",
121+
"hidden",
122+
),
123+
),
124+
rx.el.p(
125+
"Selected label: ",
126+
rx.el.strong(NerState.selected_label_name),
127+
" (Click words below to apply/remove)",
128+
class_name=rx.cond(
129+
NerState.selected_label_name is None,
130+
"hidden",
131+
"text-sm text-blue-700 font-semibold mb-2 px-4",
132+
),
133+
),
134+
rx.el.div(
135+
rx.foreach(NerState.entities, entity_button),
136+
rx.el.button(
137+
"Download Annotations",
138+
on_click=rx.download(
139+
data=NerState.annotated_text_json,
140+
filename="annotated_text.json",
141+
),
142+
class_name="bg-green-600 hover:bg-green-700 text-white font-semibold px-3 py-1 rounded text-sm transition-colors shadow-sm ml-auto",
143+
),
144+
class_name="flex flex-wrap gap-2 p-4 items-center",
145+
),
146+
)
147+
),
148+
add_label_form(),
149+
class_name="bg-gray-100 border-b border-gray-300 rounded-t-lg shadow-sm sticky top-0 z-10",
150+
)
151+
152+
153+
def render_segment(segment: Segment) -> rx.Component:
154+
"""Renders a single text segment, making it clickable if a label is selected."""
155+
is_label_selected = NerState.selected_label_name is not None
156+
is_whitespace = segment["text"].strip() == ""
157+
base_component = rx.el.span(segment["text"])
158+
labeled_class = (
159+
segment["bg_color"]
160+
+ " "
161+
+ segment["text_color"]
162+
+ " py-0.5 rounded-sm cursor-pointer"
163+
)
164+
labeled_component = rx.el.span(
165+
segment["text"],
166+
rx.el.span(
167+
segment["label_name"],
168+
class_name="text-[0.6rem] font-bold opacity-70 align-super",
169+
),
170+
class_name=labeled_class,
171+
title=segment["label_name"],
172+
)
173+
hoverable_component = rx.el.span(
174+
segment["text"],
175+
class_name="hover:bg-gray-200 rounded-sm cursor-pointer transition-colors",
176+
)
177+
styled_component = rx.cond(
178+
segment["label_name"] is not None,
179+
labeled_component,
180+
rx.cond(
181+
is_label_selected & ~is_whitespace,
182+
hoverable_component,
183+
base_component,
184+
),
185+
)
186+
on_click_event = rx.cond(
187+
is_label_selected & ~is_whitespace,
188+
NerState.apply_label(segment["id"]),
189+
rx.noop(),
190+
)
191+
clickable_component = rx.el.span(
192+
styled_component,
193+
on_click=on_click_event,
194+
class_name=rx.cond(segment["label_name"] is not None, "", "inline"),
195+
)
196+
return clickable_component
197+
198+
199+
def text_display() -> rx.Component:
200+
"""Displays the processed text with highlighted entities."""
201+
return rx.el.div(
202+
rx.el.p(
203+
rx.foreach(NerState.display_segments, render_segment),
204+
class_name="text-lg text-gray-800 whitespace-pre-wrap",
205+
),
206+
class_name="p-6 bg-white border border-gray-300 border-t-0 rounded-b-lg shadow-inner",
207+
)
208+
209+
210+
def ner_component() -> rx.Component:
211+
"""The main component combining the header and text display."""
212+
return rx.el.div(
213+
header_bar(),
214+
text_display(),
215+
class_name="max-w-5xl mx-auto my-8 shadow-lg rounded-lg font-sans",
216+
)

0 commit comments

Comments
 (0)