Skip to content

Commit 007cbd3

Browse files
committed
display widget
1 parent 57e7518 commit 007cbd3

5 files changed

Lines changed: 247 additions & 2 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,30 @@ my_profile = EntityProfile(
8787
result = extractor.extract(text, profile=my_profile)
8888
```
8989

90+
## Visualization
91+
92+
Render results as color-coded, interactive HTML directly in Jupyter notebooks:
93+
94+
```python
95+
# Auto-renders when result is the last expression in a cell
96+
result
97+
98+
# Or call explicitly
99+
result.display()
100+
```
101+
102+
Each entity type gets a distinct color with a superscript label. Hover any
103+
highlight to see its type and attributes. Click legend items to toggle
104+
categories on or off.
105+
106+
To get the raw HTML string (useful outside Jupyter):
107+
108+
```python
109+
from structflo.ner import render_html
110+
111+
html_str = render_html(result)
112+
```
113+
90114
## Working with results
91115

92116
```python

structflo/ner/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
result = extractor.extract(text, profile=my_profile)
3434
"""
3535

36+
from structflo.ner._display import display, render_html
3637
from structflo.ner._entities import (
3738
AssayEntity,
3839
BioactivityEntity,
@@ -53,7 +54,7 @@
5354
EntityProfile,
5455
)
5556

56-
__version__ = "0.1.0"
57+
__version__ = "0.1.1"
5758

5859
__all__ = [
5960
# Main class
@@ -74,4 +75,7 @@
7475
"BioactivityEntity",
7576
"AssayEntity",
7677
"MechanismEntity",
78+
# Visualization
79+
"display",
80+
"render_html",
7781
]

structflo/ner/_display.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Interactive HTML visualization for NER extraction results.
2+
3+
Renders annotated text with color-coded entity highlights, tooltips,
4+
and a filterable legend. Works in Jupyter notebooks via IPython.display.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import html
10+
import uuid
11+
from typing import TYPE_CHECKING
12+
13+
if TYPE_CHECKING:
14+
from structflo.ner._entities import NERResult
15+
16+
# ── Color palette per entity category ──────────────────────────────────
17+
18+
_COLORS: dict[str, dict[str, str]] = {
19+
"ChemicalEntity": {"bg": "#dbeafe", "border": "#3b82f6", "label": "Compound"},
20+
"TargetEntity": {"bg": "#dcfce7", "border": "#22c55e", "label": "Target"},
21+
"DiseaseEntity": {"bg": "#fce7f3", "border": "#ec4899", "label": "Disease"},
22+
"BioactivityEntity": {"bg": "#fef3c7", "border": "#f59e0b", "label": "Bioactivity"},
23+
"AssayEntity": {"bg": "#e0e7ff", "border": "#6366f1", "label": "Assay"},
24+
"MechanismEntity": {"bg": "#f3e8ff", "border": "#a855f7", "label": "Mechanism"},
25+
"NEREntity": {"bg": "#f1f5f9", "border": "#94a3b8", "label": "Other"},
26+
}
27+
28+
29+
def _color_for(entity_cls_name: str) -> dict[str, str]:
30+
return _COLORS.get(entity_cls_name, _COLORS["NEREntity"])
31+
32+
33+
# ── HTML rendering ─────────────────────────────────────────────────────
34+
35+
36+
def render_html(result: NERResult) -> str:
37+
"""Return a self-contained HTML string visualizing the NER result.
38+
39+
Entities with character offsets are rendered as inline highlights over
40+
the source text. Entities without offsets are listed in a separate
41+
section below.
42+
"""
43+
uid = uuid.uuid4().hex[:8]
44+
45+
# Partition entities into positioned and unpositioned
46+
positioned = []
47+
unpositioned = []
48+
for ent in result.all_entities():
49+
if ent.char_start is not None and ent.char_end is not None:
50+
positioned.append(ent)
51+
else:
52+
unpositioned.append(ent)
53+
54+
# Sort by start offset, then by longest span first (to handle nesting)
55+
positioned.sort(key=lambda e: (e.char_start, -(e.char_end - e.char_start)))
56+
57+
# Resolve overlaps: keep only non-overlapping spans (greedy, longest first)
58+
selected = []
59+
occupied_end = -1
60+
for ent in positioned:
61+
if ent.char_start >= occupied_end:
62+
selected.append(ent)
63+
occupied_end = ent.char_end
64+
65+
# Build annotated text
66+
text = result.source_text
67+
parts: list[str] = []
68+
cursor = 0
69+
for ent in selected:
70+
color = _color_for(type(ent).__name__)
71+
# Plain text before entity
72+
if ent.char_start > cursor:
73+
parts.append(html.escape(text[cursor : ent.char_start]))
74+
# Tooltip content
75+
tips = [f"type: {ent.entity_type}"]
76+
if ent.attributes:
77+
tips.extend(f"{k}: {v}" for k, v in ent.attributes.items())
78+
tooltip = html.escape(" | ".join(tips))
79+
# Entity span
80+
parts.append(
81+
f'<mark class="ner-ent" '
82+
f'data-category="{type(ent).__name__}" '
83+
f'style="background:{color["bg"]};border-bottom:2px solid {color["border"]};'
84+
f'padding:2px 4px;border-radius:4px;cursor:default" '
85+
f'title="{tooltip}">'
86+
f"{html.escape(text[ent.char_start : ent.char_end])}"
87+
f'<span style="font-size:0.7em;font-weight:600;vertical-align:super;'
88+
f'margin-left:2px;color:{color["border"]}">'
89+
f"{html.escape(ent.entity_type)}</span></mark>"
90+
)
91+
cursor = ent.char_end
92+
# Remaining text
93+
if cursor < len(text):
94+
parts.append(html.escape(text[cursor:]))
95+
96+
annotated_html = "".join(parts)
97+
98+
# Unpositioned entities table
99+
unpositioned_section = ""
100+
if unpositioned:
101+
rows = []
102+
for ent in unpositioned:
103+
color = _color_for(type(ent).__name__)
104+
attrs = ", ".join(f"{k}={v}" for k, v in ent.attributes.items()) or "—"
105+
rows.append(
106+
f"<tr data-category=\"{type(ent).__name__}\">"
107+
f'<td style="padding:4px 8px"><span style="display:inline-block;'
108+
f"width:10px;height:10px;border-radius:50%;background:{color['border']};"
109+
f'margin-right:6px"></span>{html.escape(ent.text)}</td>'
110+
f'<td style="padding:4px 8px;color:#64748b">'
111+
f"{html.escape(ent.entity_type)}</td>"
112+
f'<td style="padding:4px 8px;color:#64748b;font-size:0.85em">'
113+
f"{html.escape(attrs)}</td></tr>"
114+
)
115+
unpositioned_section = (
116+
'<div style="margin-top:16px">'
117+
'<h4 style="margin:0 0 6px;font-size:0.85em;color:#64748b">'
118+
"Entities without text spans</h4>"
119+
'<table style="border-collapse:collapse;width:100%;font-size:0.9em">'
120+
'<thead><tr style="border-bottom:1px solid #e2e8f0">'
121+
'<th style="text-align:left;padding:4px 8px">Text</th>'
122+
'<th style="text-align:left;padding:4px 8px">Type</th>'
123+
'<th style="text-align:left;padding:4px 8px">Attributes</th>'
124+
"</tr></thead><tbody>"
125+
+ "".join(rows)
126+
+ "</tbody></table></div>"
127+
)
128+
129+
# Legend
130+
seen = {type(e).__name__ for e in result.all_entities()}
131+
legend_items = []
132+
for cls_name, color in _COLORS.items():
133+
if cls_name not in seen:
134+
continue
135+
legend_items.append(
136+
f'<button class="ner-legend-btn" data-target="{cls_name}" '
137+
f'style="display:inline-flex;align-items:center;gap:4px;'
138+
f"padding:4px 10px;border:1.5px solid {color['border']};border-radius:16px;"
139+
f"background:{color['bg']};cursor:pointer;font-size:0.8em;font-weight:500;"
140+
f'font-family:inherit">'
141+
f'<span style="width:8px;height:8px;border-radius:50%;'
142+
f"background:{color['border']}\"></span>"
143+
f"{html.escape(color['label'])}</button>"
144+
)
145+
legend_html = (
146+
'<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px">'
147+
+ "".join(legend_items)
148+
+ "</div>"
149+
)
150+
151+
# Entity counts summary
152+
counts = {}
153+
for ent in result.all_entities():
154+
label = _color_for(type(ent).__name__)["label"]
155+
counts[label] = counts.get(label, 0) + 1
156+
count_parts = " · ".join(f"<b>{v}</b> {k}" for k, v in counts.items())
157+
summary = (
158+
f'<div style="font-size:0.8em;color:#64748b;margin-bottom:10px">'
159+
f"{count_parts}</div>"
160+
)
161+
162+
# JS for toggling entity categories
163+
script = (
164+
f"<script>"
165+
f"(function(){{"
166+
f"var w=document.getElementById('ner-{uid}');"
167+
f"w.querySelectorAll('.ner-legend-btn').forEach(function(btn){{"
168+
f"btn.addEventListener('click',function(){{"
169+
f"var cat=btn.dataset.target;"
170+
f"var active=btn.dataset.active!=='false';"
171+
f"btn.dataset.active=active?'false':'true';"
172+
f"btn.style.opacity=active?'0.35':'1';"
173+
f"w.querySelectorAll('[data-category=\"'+cat+'\"]').forEach(function(el){{"
174+
f"if(el.classList.contains('ner-ent'))"
175+
f"el.style.background=active?'transparent':el.style.borderBottomColor.replace('solid ','');"
176+
f"el.style.opacity=active?'0.3':'1';"
177+
f"}});"
178+
f"}});"
179+
f"}});"
180+
f"}})()</script>"
181+
)
182+
183+
return (
184+
f'<div id="ner-{uid}" style="font-family:system-ui,-apple-system,sans-serif;'
185+
f'max-width:900px;padding:20px">'
186+
f"{legend_html}{summary}"
187+
f'<div style="line-height:2;font-size:1em;white-space:pre-wrap">'
188+
f"{annotated_html}</div>"
189+
f"{unpositioned_section}"
190+
f"{script}</div>"
191+
)
192+
193+
194+
def display(result: NERResult) -> None:
195+
"""Render the NER result as interactive HTML in a Jupyter notebook."""
196+
try:
197+
from IPython.display import HTML # noqa: PLC0415
198+
from IPython.display import display as ipy_display # noqa: PLC0415
199+
except ImportError as exc:
200+
raise ImportError(
201+
"IPython is required for display(). "
202+
"Use render_html() to get the raw HTML string instead."
203+
) from exc
204+
205+
ipy_display(HTML(render_html(result)))

structflo/ner/_entities.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,18 @@ def to_dict(self) -> dict:
125125
"unclassified": [dataclasses.asdict(e) for e in self.unclassified],
126126
}
127127

128+
def display(self) -> None:
129+
"""Render the result as interactive HTML in a Jupyter notebook."""
130+
from structflo.ner._display import display as _display # noqa: PLC0415
131+
132+
_display(self)
133+
134+
def _repr_html_(self) -> str:
135+
"""Auto-render as HTML when displayed in Jupyter."""
136+
from structflo.ner._display import render_html # noqa: PLC0415
137+
138+
return render_html(self)
139+
128140
def to_dataframe(self) -> pd.DataFrame:
129141
"""Return all entities as a flat pandas DataFrame.
130142

structflo/ner/extractor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def _run_extraction(
122122
prompt = self._build_prompt(profile)
123123

124124
kwargs: dict = dict(self._langextract_kwargs)
125-
kwargs.setdefault("use_schema_constraints", True)
125+
kwargs.setdefault("use_schema_constraints", False)
126126
kwargs.setdefault("show_progress", False)
127127

128128
result = lx.extract(

0 commit comments

Comments
 (0)