Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 69 additions & 18 deletions ax/core/analysis_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
from plotly.offline import get_plotlyjs

# Simple HTML template for rendering a card with a title, subtitle, and body with
# scrollable overflow.
# scrollable overflow. Subtitles are rendered in a <div> with max-height overflow
# so that any HTML content (tables, lists, etc.) is valid and clipped correctly
# in the collapsed state. A "See more"/"See less" toggle appears when content
# overflows (matching the web UI pattern).
html_card_template = """
<style>
.card {{
Expand All @@ -30,34 +33,66 @@
border-radius: 0.5em;
padding: 10px;
}}
.card-header:hover {{
cursor: pointer;
}}
.card-header details summary {{
list-style: none;
display: inline-flex;
align-items: center;
gap: 0.5em;
.card-subtitle {{
color: gray;
font-size: 0.9em;
margin: 4px 0 0 0;
line-height: 1.4em;
max-height: 1.4em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}}
.card-header details summary::-webkit-details-marker {{
.card-subtitle-toggle {{
color: #1877F2;
cursor: pointer;
font-size: 0.85em;
background: none;
border: none;
padding: 0;
margin-top: 2px;
display: none;
}}
.card-header details summary::after {{
content: "ℹ️";
font-size: 0.9em;
}}
</style>
<div class="card">
<div class="card-header">
<details>
<summary><b>{title_str}</b></summary>
<p>{subtitle_str}</p>
</details>
<b>{title_str}</b>
<div class="card-subtitle">{subtitle_str}</div>
<button class="card-subtitle-toggle">{toggle_text}</button>
</div>
<div class="card-body">
{body_html}
</div>
</div>
<script>
(function() {{
var card = document.currentScript.previousElementSibling;
var subtitle = card.querySelector('.card-subtitle');
var toggle = card.querySelector('.card-subtitle-toggle');
if (subtitle && toggle) {{
var originalText = toggle.textContent;
requestAnimationFrame(function() {{
if (subtitle.scrollHeight > subtitle.clientHeight
|| subtitle.scrollWidth > subtitle.clientWidth) {{
toggle.style.display = 'inline';
}}
}});
toggle.onclick = function() {{
if (subtitle.style.maxHeight === 'none') {{
subtitle.style.maxHeight = '1.4em';
subtitle.style.whiteSpace = 'nowrap';
subtitle.style.textOverflow = 'ellipsis';
toggle.textContent = originalText;
}} else {{
subtitle.style.maxHeight = 'none';
subtitle.style.whiteSpace = 'normal';
subtitle.style.textOverflow = 'clip';
toggle.textContent = 'See less';
}}
}};
}}
}})();
</script>
"""

# Simple HTML template for rendering a *group* card with a title, subtitle, and
Expand Down Expand Up @@ -120,6 +155,7 @@ class AnalysisCardBase(SortableBase, ABC):

title: str
subtitle: str
subtitle_toggle_label: str

_timestamp: datetime

Expand All @@ -129,6 +165,7 @@ def __init__(
title: str,
subtitle: str,
timestamp: datetime | None = None,
subtitle_toggle_label: str = "",
) -> None:
"""
Args:
Expand All @@ -141,10 +178,14 @@ def __init__(
timestamp: The time at which the Analysis was computed. This can be
especially useful when querying the database for the most recently
produced artifacts.
subtitle_toggle_label: Custom label for the subtitle expansion
toggle. When non-empty, replaces the default "See more" text. Persisted to
storage and used by both notebook and web UI rendering.
"""
self.name = name
self.title = title
self.subtitle = subtitle
self.subtitle_toggle_label = subtitle_toggle_label
self._timestamp = timestamp if timestamp is not None else datetime.now()

@abstractmethod
Expand Down Expand Up @@ -224,10 +265,12 @@ def _repr_html_(self) -> str:
return plotlyjs_script + self._to_html(depth=0)

def _to_html(self, depth: int) -> str:
toggle_text = self.subtitle_toggle_label or "See more"
return html_card_template.format(
title_str=self.title,
subtitle_str=self.subtitle,
body_html=self._body_html(depth=depth),
toggle_text=toggle_text,
)


Expand All @@ -248,6 +291,7 @@ def __init__(
subtitle: str | None,
children: Sequence[AnalysisCardBase],
timestamp: datetime | None = None,
subtitle_toggle_label: str = "",
) -> None:
"""
Args:
Expand All @@ -260,12 +304,15 @@ def __init__(
timestamp: The time at which the Analysis was computed. This can be
especially useful when querying the database for the most recently
produced artifacts.
subtitle_toggle_label: Custom label for the subtitle expansion
toggle. When non-empty, replaces the default "See more" text
"""
super().__init__(
name=name,
title=title,
subtitle=subtitle if subtitle is not None else "",
timestamp=timestamp,
subtitle_toggle_label=subtitle_toggle_label,
)

self.children = [
Expand Down Expand Up @@ -370,6 +417,7 @@ def __init__(
df: pd.DataFrame,
blob: str,
timestamp: datetime | None = None,
subtitle_toggle_label: str = "",
) -> None:
"""
Args:
Expand All @@ -383,12 +431,15 @@ def __init__(
timestamp: The time at which the Analysis was computed. This can be
especially useful when querying the database for the most recently
produced artifacts.
subtitle_toggle_label: Custom label for the subtitle expansion
toggle. When non-empty, replaces the default "See more" text
"""
super().__init__(
name=name,
title=title,
subtitle=subtitle,
timestamp=timestamp,
subtitle_toggle_label=subtitle_toggle_label,
)

self.df = df
Expand Down
22 changes: 22 additions & 0 deletions ax/core/tests/test_analysis_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,25 @@ def test_not_applicable_card(self) -> None:
blob="Explanation text.",
)
self.assertIn("Explanation text.", card._body_html(depth=0))

def test_subtitle_toggle_label_rendering(self) -> None:
"""Verify subtitle_toggle_label controls toggle button text in HTML."""
for label, expected_text in (
("", "See more"),
(
"Expand to see annotated parameters.",
"Expand to see annotated parameters.",
),
):
with self.subTest():
card = AnalysisCard(
name="Test",
title="Title",
subtitle="A long subtitle",
df=pd.DataFrame(),
blob="blob",
subtitle_toggle_label=label,
)
self.assertEqual(card.subtitle_toggle_label, label)
html = card._repr_html_()
self.assertIn(expected_text, html)
2 changes: 2 additions & 0 deletions ax/storage/json_store/encoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def analysis_card_to_dict(card: AnalysisCard) -> dict[str, Any]:
"df": card.df,
"blob": card.blob,
"timestamp": card._timestamp,
"subtitle_toggle_label": card.subtitle_toggle_label,
}


Expand All @@ -101,6 +102,7 @@ def analysis_card_group_to_dict(group: AnalysisCardGroup) -> dict[str, Any]:
"subtitle": group.subtitle,
"children": group.children,
"timestamp": group._timestamp,
"subtitle_toggle_label": group.subtitle_toggle_label,
}


Expand Down
2 changes: 2 additions & 0 deletions ax/storage/json_store/tests/test_json_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@
subtitle="subtitle",
df=pd.DataFrame({"a": [1, 2]}),
blob="blob_str",
subtitle_toggle_label="Expand to see details.",
),
),
(
Expand Down Expand Up @@ -495,6 +496,7 @@
blob="# md",
),
],
subtitle_toggle_label="Expand to see children.",
),
),
]
Expand Down
10 changes: 10 additions & 0 deletions ax/storage/sqa_store/decoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1163,19 +1163,22 @@ def analysis_card_from_sqa(
analysis_card_sqa.title if analysis_card_sqa.title is not None else ""
)
subtitle = analysis_card_sqa.subtitle
subtitle_toggle_label = analysis_card_sqa.subtitle_toggle_label or ""

return AnalysisCardGroup(
name=analysis_card_sqa.name,
title=title,
subtitle=subtitle,
children=children,
timestamp=analysis_card_sqa.timestamp,
subtitle_toggle_label=subtitle_toggle_label,
)

title = none_throws(analysis_card_sqa.title)
subtitle = none_throws(analysis_card_sqa.subtitle)
blob = none_throws(analysis_card_sqa.blob)
blob_annotation = analysis_card_sqa.blob_annotation
subtitle_toggle_label = analysis_card_sqa.subtitle_toggle_label or ""

if blob_annotation == "not_applicable_state":
return NotApplicableStateAnalysisCard(
Expand All @@ -1185,6 +1188,7 @@ def analysis_card_from_sqa(
df=read_json(StringIO(analysis_card_sqa.dataframe_json)),
blob=blob,
timestamp=analysis_card_sqa.timestamp,
subtitle_toggle_label=subtitle_toggle_label,
)
if blob_annotation == "error":
return ErrorAnalysisCard(
Expand All @@ -1194,6 +1198,7 @@ def analysis_card_from_sqa(
df=read_json(StringIO(analysis_card_sqa.dataframe_json)),
blob=blob,
timestamp=analysis_card_sqa.timestamp,
subtitle_toggle_label=subtitle_toggle_label,
)
if blob_annotation == "plotly":
return PlotlyAnalysisCard(
Expand All @@ -1203,6 +1208,7 @@ def analysis_card_from_sqa(
df=read_json(StringIO(analysis_card_sqa.dataframe_json)),
blob=blob,
timestamp=analysis_card_sqa.timestamp,
subtitle_toggle_label=subtitle_toggle_label,
)
if blob_annotation == "markdown":
return MarkdownAnalysisCard(
Expand All @@ -1212,6 +1218,7 @@ def analysis_card_from_sqa(
df=read_json(StringIO(analysis_card_sqa.dataframe_json)),
blob=blob,
timestamp=analysis_card_sqa.timestamp,
subtitle_toggle_label=subtitle_toggle_label,
)
if blob_annotation == "healthcheck":
return HealthcheckAnalysisCard(
Expand All @@ -1221,6 +1228,7 @@ def analysis_card_from_sqa(
df=read_json(StringIO(analysis_card_sqa.dataframe_json)),
blob=blob,
timestamp=analysis_card_sqa.timestamp,
subtitle_toggle_label=subtitle_toggle_label,
)
if blob_annotation == "graphviz":
return GraphvizAnalysisCard(
Expand All @@ -1230,6 +1238,7 @@ def analysis_card_from_sqa(
df=read_json(StringIO(analysis_card_sqa.dataframe_json)),
blob=blob,
timestamp=analysis_card_sqa.timestamp,
subtitle_toggle_label=subtitle_toggle_label,
)
return AnalysisCard(
name=analysis_card_sqa.name,
Expand All @@ -1238,6 +1247,7 @@ def analysis_card_from_sqa(
df=read_json(StringIO(analysis_card_sqa.dataframe_json)),
blob=blob,
timestamp=analysis_card_sqa.timestamp,
subtitle_toggle_label=subtitle_toggle_label,
)

def _metric_from_sqa_util(self, metric_sqa: SQAMetric) -> Metric:
Expand Down
2 changes: 2 additions & 0 deletions ax/storage/sqa_store/encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1264,6 +1264,7 @@ def analysis_card_to_sqa(
dataframe_json=None,
blob=None,
blob_annotation=None,
subtitle_toggle_label=analysis_card.subtitle_toggle_label or None,
)

for i, child_card in enumerate(analysis_card.children):
Expand Down Expand Up @@ -1304,4 +1305,5 @@ def analysis_card_to_sqa(
dataframe_json=card.df.to_json(),
blob=card.blob,
blob_annotation=blob_annotation,
subtitle_toggle_label=card.subtitle_toggle_label or None,
)
3 changes: 3 additions & 0 deletions ax/storage/sqa_store/sqa_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,9 @@ class SQAAnalysisCard(Base):
blob_annotation: Column[str | None] = Column(
String(NAME_OR_TYPE_FIELD_LENGTH), nullable=True
)
subtitle_toggle_label: Column[str | None] = Column(
"subtitle_toggle_label", String(LONG_STRING_FIELD_LENGTH), nullable=True
)
parent: Any = relationship(
"SQAAnalysisCard",
back_populates="children",
Expand Down
Loading
Loading