From 647afbc83aec15eb3148f0511b8a7f407d6e5463 Mon Sep 17 00:00:00 2001 From: Irozuku Date: Fri, 10 Jul 2026 15:18:33 -0400 Subject: [PATCH 1/8] feat: add typed artifact contract for renderable outputs --- DashAI/back/core/artifacts.py | 362 ++++++++++++++++++++++++++++++ tests/back/core/__init__.py | 0 tests/back/core/test_artifacts.py | 189 ++++++++++++++++ 3 files changed, 551 insertions(+) create mode 100644 DashAI/back/core/artifacts.py create mode 100644 tests/back/core/__init__.py create mode 100644 tests/back/core/test_artifacts.py diff --git a/DashAI/back/core/artifacts.py b/DashAI/back/core/artifacts.py new file mode 100644 index 000000000..908ce897b --- /dev/null +++ b/DashAI/back/core/artifacts.py @@ -0,0 +1,362 @@ +"""Typed render artifacts shared by explainers and explorers. + +An artifact is the unit of renderable output that a component (an explainer's +``plot`` method, an explorer's ``get_results`` method) hands to the frontend. +Every artifact serializes to the wire format:: + + {"type": , "payload": , "title": } + +Supported types: + +- ``"plotly"``: payload is a JSON string produced by ``plotly.io.to_json``. +- ``"table"``: payload is ``{"columns": List[str], "rows": List[List[Any]], + "highlight": List[{"row": int, "column": int}]}``. +- ``"text"``: payload is a plain string rendered as preformatted text. +- ``"image"``: payload is ``{"data": , "mime": }``. + +Components created before this module returned other shapes: explainers +returned lists of plotly JSON strings, explorers returned a single +``{"data", "type", "config"}`` dict. :func:`normalize_artifacts` upgrades +both legacy shapes, so old pickled explanations, old saved explorations and +legacy plugin components keep working. +""" + +import base64 +from typing import Annotated, Any, Dict, List, Literal, Optional, Union + +from pydantic import ( + BaseModel, + Field, + TypeAdapter, + ValidationError, + field_validator, + model_validator, +) + +_IMAGE_MAGIC_MIMES = ( + (b"\x89PNG\r\n\x1a\n", "image/png"), + (b"\xff\xd8\xff", "image/jpeg"), + (b"GIF87a", "image/gif"), + (b"GIF89a", "image/gif"), + (b"BM", "image/bmp"), +) + + +def _detect_mime(data: bytes) -> str: + """Guess the MIME type of raw image bytes from their magic numbers. + + Parameters + ---------- + data : bytes + Raw image bytes. + + Returns + ------- + str + The detected MIME type, or ``"image/png"`` when unknown. + """ + for magic, mime in _IMAGE_MAGIC_MIMES: + if data.startswith(magic): + return mime + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "image/webp" + return "image/png" + + +class Artifact(BaseModel): + """Base class for typed render artifacts. + + Attributes + ---------- + type : str + Discriminator naming the artifact kind; fixed per subclass. + title : Optional[str] + Human readable title shown above the rendered artifact. + """ + + type: str + title: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Serialize the artifact to its wire format. + + Returns + ------- + Dict[str, Any] + ``{"type", "payload", "title"}`` with a JSON-serializable payload. + """ + return self.model_dump() + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Artifact": + """Deserialize a wire format dict into the matching artifact subclass. + + Parameters + ---------- + data : Dict[str, Any] + A dict with ``type``, ``payload`` and optionally ``title``. + + Returns + ------- + Artifact + An instance of the subclass matching ``data["type"]``. + + Raises + ------ + ValueError + If the type is unknown or the payload is malformed. + """ + try: + return _ANY_ARTIFACT_ADAPTER.validate_python(data) + except ValidationError as error: + raise ValueError(f"Invalid artifact dict: {error}") from error + + +class PlotlyArtifact(Artifact): + """Artifact holding a plotly figure serialized as JSON. + + Attributes + ---------- + payload : str + JSON string produced by ``plotly.io.to_json``. A live plotly + ``Figure`` may be passed instead; it is serialized on validation. + """ + + type: Literal["plotly"] = "plotly" + payload: str + + @field_validator("payload", mode="before") + @classmethod + def _serialize_figure(cls, value: Any) -> Any: + if not isinstance(value, str) and hasattr(value, "to_plotly_json"): + import plotly.io as pio + + return pio.to_json(value) + return value + + +class TableCell(BaseModel): + """Reference to a single table cell, 0-indexed relative to ``rows``. + + Attributes + ---------- + row : int + Row index of the cell. + column : int + Column index of the cell. + """ + + row: int = Field(ge=0) + column: int = Field(ge=0) + + +class TablePayload(BaseModel): + """Payload of a table artifact. + + Attributes + ---------- + columns : List[str] + Column headers. + rows : List[List[Any]] + Table rows; every row must have ``len(columns)`` cells. + highlight : List[TableCell] + Cells to emphasise when rendered. + """ + + columns: List[str] + rows: List[List[Any]] + highlight: List[TableCell] = Field(default_factory=list) + + @model_validator(mode="after") + def _check_shape(self) -> "TablePayload": + for i, row in enumerate(self.rows): + if len(row) != len(self.columns): + raise ValueError( + f"Row {i} has {len(row)} cells, expected {len(self.columns)}." + ) + for cell in self.highlight: + if cell.row >= len(self.rows) or cell.column >= len(self.columns): + raise ValueError( + f"Highlight cell ({cell.row}, {cell.column}) is out of bounds." + ) + return self + + +class TableArtifact(Artifact): + """Artifact holding tabular data with optional highlighted cells. + + Attributes + ---------- + payload : TablePayload + Columns, rows and highlighted cells. + """ + + type: Literal["table"] = "table" + payload: TablePayload + + +class TextArtifact(Artifact): + """Artifact holding plain text rendered preformatted. + + Attributes + ---------- + payload : str + Text content; newlines are preserved when rendered. + """ + + type: Literal["text"] = "text" + payload: str + + +class ImagePayload(BaseModel): + """Payload of an image artifact. + + Attributes + ---------- + data : str + Base64-encoded image bytes. Raw ``bytes`` may be passed instead; + they are encoded on validation. + mime : str + MIME type of the encoded image. + """ + + data: str + mime: str = "image/png" + + @field_validator("data", mode="before") + @classmethod + def _encode_bytes(cls, value: Any) -> Any: + if isinstance(value, (bytes, bytearray)): + return base64.b64encode(bytes(value)).decode("ascii") + if isinstance(value, str): + try: + base64.b64decode(value, validate=True) + except Exception as error: + raise ValueError("data is not valid base64.") from error + return value + + +class ImageArtifact(Artifact): + """Artifact holding a base64-encoded image. + + Attributes + ---------- + payload : ImagePayload + Base64 data and MIME type. + """ + + type: Literal["image"] = "image" + payload: ImagePayload + + @classmethod + def from_dashai_image( + cls, image: Any, title: Optional[str] = None + ) -> "ImageArtifact": + """Build an image artifact from a dataset :class:`DashAIImage` value. + + Parameters + ---------- + image : DashAIImage + A dataset image instance carrying raw bytes. + title : Optional[str] + Human readable title shown above the artifact. + + Returns + ------- + ImageArtifact + The artifact wrapping the image bytes. + + Raises + ------ + ValueError + If the image has no bytes available. + """ + if getattr(image, "bytes", None) is None: + raise ValueError("DashAIImage has no bytes available.") + return cls( + payload=ImagePayload(data=image.bytes, mime=_detect_mime(image.bytes)), + title=title, + ) + + +AnyArtifact = Annotated[ + Union[PlotlyArtifact, TableArtifact, TextArtifact, ImageArtifact], + Field(discriminator="type"), +] + +_ANY_ARTIFACT_ADAPTER: TypeAdapter = TypeAdapter(AnyArtifact) + + +def _legacy_explorer_artifact(item: Dict[str, Any]) -> Dict[str, Any]: + """Convert a legacy explorer result dict into an artifact dict. + + Parameters + ---------- + item : Dict[str, Any] + A dict with the old explorer contract ``{"data", "type", "config"}``. + + Returns + ------- + Dict[str, Any] + The equivalent artifact dict; unknown legacy types degrade to a + text artifact. + """ + legacy_type = item.get("type") + data = item.get("data") + if legacy_type == "plotly_json" and isinstance(data, str): + return PlotlyArtifact(payload=data).to_dict() + if legacy_type == "tabular" and isinstance(data, dict): + columns = ["index", *data.keys()] + index_keys: List[Any] = [] + for column_values in data.values(): + if isinstance(column_values, dict): + for key in column_values: + if key not in index_keys: + index_keys.append(key) + rows = [ + [key, *(data[column].get(key) for column in data)] for key in index_keys + ] + return TableArtifact(payload=TablePayload(columns=columns, rows=rows)).to_dict() + if legacy_type == "image_base64" and isinstance(data, str): + return ImageArtifact(payload=ImagePayload(data=data)).to_dict() + return TextArtifact(payload=str(data)).to_dict() + + +def normalize_artifacts(items: Any) -> List[Dict[str, Any]]: + """Coerce any component output into a list of artifact wire dicts. + + Handles current values (``Artifact`` instances or artifact dicts) and + legacy shapes: plain plotly JSON strings from old explainers, and + ``{"data", "type", "config"}`` dicts from old explorers. Anything else + is stringified into a text artifact so the frontend never receives an + unrenderable value. + + Parameters + ---------- + items : Any + The value returned by an explainer ``plot`` method, an explorer + ``get_results`` method, or loaded from a persisted result file. + + Returns + ------- + List[Dict[str, Any]] + A list of artifact dicts in wire format. + """ + if items is None: + return [] + if isinstance(items, (str, dict, Artifact)): + items = [items] + + artifacts: List[Dict[str, Any]] = [] + for item in items: + if isinstance(item, Artifact): + artifacts.append(item.to_dict()) + elif isinstance(item, str): + artifacts.append(PlotlyArtifact(payload=item).to_dict()) + elif isinstance(item, dict) and "type" in item and "payload" in item: + artifacts.append({"title": None, **item}) + elif isinstance(item, dict) and "type" in item and "data" in item: + artifacts.append(_legacy_explorer_artifact(item)) + else: + artifacts.append(TextArtifact(payload=str(item)).to_dict()) + return artifacts diff --git a/tests/back/core/__init__.py b/tests/back/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/back/core/test_artifacts.py b/tests/back/core/test_artifacts.py new file mode 100644 index 000000000..a65c34d75 --- /dev/null +++ b/tests/back/core/test_artifacts.py @@ -0,0 +1,189 @@ +import base64 +import json + +import pytest + +from DashAI.back.core.artifacts import ( + Artifact, + ImageArtifact, + ImagePayload, + PlotlyArtifact, + TableArtifact, + TableCell, + TablePayload, + TextArtifact, + normalize_artifacts, +) + +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8 + + +def test_plotly_artifact_to_dict(): + artifact = PlotlyArtifact(payload='{"data": []}', title="A plot") + assert artifact.to_dict() == { + "type": "plotly", + "payload": '{"data": []}', + "title": "A plot", + } + + +def test_plotly_artifact_accepts_figure(): + import plotly.graph_objects as go + + figure = go.Figure(data=[go.Bar(x=["a"], y=[1])]) + artifact = PlotlyArtifact(payload=figure) + parsed = json.loads(artifact.payload) + assert parsed["data"][0]["type"] == "bar" + + +def test_table_artifact_to_dict(): + artifact = TableArtifact( + payload=TablePayload( + columns=["a", "b"], + rows=[[1, 2], [3, 4]], + highlight=[TableCell(row=1, column=0)], + ), + title="A table", + ) + assert artifact.to_dict() == { + "type": "table", + "payload": { + "columns": ["a", "b"], + "rows": [[1, 2], [3, 4]], + "highlight": [{"row": 1, "column": 0}], + }, + "title": "A table", + } + + +def test_table_artifact_rejects_ragged_rows(): + with pytest.raises(ValueError, match="expected 2"): + TablePayload(columns=["a", "b"], rows=[[1, 2], [3]]) + + +def test_table_artifact_rejects_out_of_bounds_highlight(): + with pytest.raises(ValueError, match="out of bounds"): + TablePayload( + columns=["a"], + rows=[[1]], + highlight=[TableCell(row=1, column=0)], + ) + + +def test_text_artifact_to_dict(): + artifact = TextArtifact(payload="line 1\nline 2") + assert artifact.to_dict() == { + "type": "text", + "payload": "line 1\nline 2", + "title": None, + } + + +def test_image_artifact_encodes_bytes(): + artifact = ImageArtifact(payload=ImagePayload(data=PNG_BYTES)) + decoded = base64.b64decode(artifact.payload.data) + assert decoded == PNG_BYTES + + +def test_image_artifact_rejects_invalid_base64(): + with pytest.raises(ValueError, match="base64"): + ImagePayload(data="not base64!!") + + +def test_image_artifact_from_dashai_image(): + from DashAI.back.types.dashai_image import DashAIImage + + image = DashAIImage(bytes=PNG_BYTES, path="img.png") + artifact = ImageArtifact.from_dashai_image(image, title="An image") + assert artifact.payload.mime == "image/png" + assert base64.b64decode(artifact.payload.data) == PNG_BYTES + assert artifact.title == "An image" + + +def test_image_artifact_from_dashai_image_without_bytes(): + from DashAI.back.types.dashai_image import DashAIImage + + with pytest.raises(ValueError, match="no bytes"): + ImageArtifact.from_dashai_image(DashAIImage()) + + +@pytest.mark.parametrize( + "artifact", + [ + PlotlyArtifact(payload='{"data": []}', title="p"), + TableArtifact(payload=TablePayload(columns=["a"], rows=[[1]])), + TextArtifact(payload="hello"), + ImageArtifact(payload=ImagePayload(data=PNG_BYTES)), + ], +) +def test_from_dict_round_trip(artifact): + restored = Artifact.from_dict(artifact.to_dict()) + assert type(restored) is type(artifact) + assert restored == artifact + + +def test_from_dict_rejects_unknown_type(): + with pytest.raises(ValueError, match="Invalid artifact"): + Artifact.from_dict({"type": "hologram", "payload": "x"}) + + +def test_from_dict_rejects_malformed_payload(): + with pytest.raises(ValueError, match="Invalid artifact"): + Artifact.from_dict({"type": "table", "payload": {"columns": ["a"]}}) + + +def test_normalize_none_is_empty(): + assert normalize_artifacts(None) == [] + + +def test_normalize_legacy_plotly_strings(): + artifacts = normalize_artifacts(['{"data": []}']) + assert artifacts == [{"type": "plotly", "payload": '{"data": []}', "title": None}] + + +def test_normalize_wraps_single_values(): + assert normalize_artifacts('{"data": []}')[0]["type"] == "plotly" + assert normalize_artifacts(TextArtifact(payload="x"))[0]["type"] == "text" + + +def test_normalize_artifact_instances(): + artifacts = normalize_artifacts([TextArtifact(payload="x", title="t")]) + assert artifacts == [{"type": "text", "payload": "x", "title": "t"}] + + +def test_normalize_passes_artifact_dicts_through(): + item = {"type": "text", "payload": "x"} + assert normalize_artifacts([item]) == [ + {"type": "text", "payload": "x", "title": None} + ] + + +def test_normalize_legacy_explorer_plotly(): + legacy = {"type": "plotly_json", "data": '{"data": []}', "config": {}} + artifacts = normalize_artifacts([legacy]) + assert artifacts == [{"type": "plotly", "payload": '{"data": []}', "title": None}] + + +def test_normalize_legacy_explorer_tabular(): + legacy = { + "type": "tabular", + "data": {"a": {"r1": 1, "r2": 2}, "b": {"r1": 3, "r2": 4}}, + "config": {"orient": "dict"}, + } + [artifact] = normalize_artifacts([legacy]) + assert artifact["type"] == "table" + assert artifact["payload"]["columns"] == ["index", "a", "b"] + assert artifact["payload"]["rows"] == [["r1", 1, 3], ["r2", 2, 4]] + + +def test_normalize_legacy_explorer_image(): + encoded = base64.b64encode(PNG_BYTES).decode("ascii") + legacy = {"type": "image_base64", "data": encoded, "config": {}} + [artifact] = normalize_artifacts([legacy]) + assert artifact["type"] == "image" + assert artifact["payload"]["data"] == encoded + + +def test_normalize_unrenderable_falls_back_to_text(): + [artifact] = normalize_artifacts([42]) + assert artifact == {"type": "text", "payload": "42", "title": None} From 3deb712398edb93f7a51399a6bcb38b83f763364 Mon Sep 17 00:00:00 2001 From: Irozuku Date: Fri, 10 Jul 2026 15:28:36 -0400 Subject: [PATCH 2/8] fix: reuse filetype library for image mime detection --- DashAI/back/core/artifacts.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/DashAI/back/core/artifacts.py b/DashAI/back/core/artifacts.py index 908ce897b..00dc49fab 100644 --- a/DashAI/back/core/artifacts.py +++ b/DashAI/back/core/artifacts.py @@ -33,14 +33,6 @@ model_validator, ) -_IMAGE_MAGIC_MIMES = ( - (b"\x89PNG\r\n\x1a\n", "image/png"), - (b"\xff\xd8\xff", "image/jpeg"), - (b"GIF87a", "image/gif"), - (b"GIF89a", "image/gif"), - (b"BM", "image/bmp"), -) - def _detect_mime(data: bytes) -> str: """Guess the MIME type of raw image bytes from their magic numbers. @@ -55,12 +47,10 @@ def _detect_mime(data: bytes) -> str: str The detected MIME type, or ``"image/png"`` when unknown. """ - for magic, mime in _IMAGE_MAGIC_MIMES: - if data.startswith(magic): - return mime - if data[:4] == b"RIFF" and data[8:12] == b"WEBP": - return "image/webp" - return "image/png" + import filetype + + kind = filetype.guess(data) + return kind.mime if kind is not None else "image/png" class Artifact(BaseModel): From 58c6262dc3ae8152c5b4925a8823a61953ab1fe6 Mon Sep 17 00:00:00 2001 From: Irozuku Date: Fri, 10 Jul 2026 15:29:33 -0400 Subject: [PATCH 3/8] feat: return artifacts from explainer plot methods --- .../back/api/api_v1/endpoints/explainers.py | 14 ++++-- .../explainability/explainers/kernel_shap.py | 50 ++++++++++++------- .../explainers/partial_dependence.py | 39 ++++++++------- .../permutation_feature_importance.py | 18 +++---- .../back/explainability/global_explainer.py | 21 ++++---- DashAI/back/explainability/local_explainer.py | 20 ++++---- DashAI/back/job/explainer_job.py | 9 ++-- tests/back/explainers/test_explainers.py | 10 ++++ 8 files changed, 111 insertions(+), 70 deletions(-) diff --git a/DashAI/back/api/api_v1/endpoints/explainers.py b/DashAI/back/api/api_v1/endpoints/explainers.py index 38ee9be8f..bcbaaab6d 100755 --- a/DashAI/back/api/api_v1/endpoints/explainers.py +++ b/DashAI/back/api/api_v1/endpoints/explainers.py @@ -158,7 +158,8 @@ async def get_global_explanation_plot( Returns ------- List[dict] - A JSON with the explanation plot. + A list of artifact dicts (``{"type", "payload", "title"}``) with the + explanation plots. Raises ------ @@ -168,6 +169,8 @@ async def get_global_explanation_plot( """ import pickle + from DashAI.back.core.artifacts import normalize_artifacts + with session_factory() as db: try: global_explainer = db.scalars( @@ -198,7 +201,7 @@ async def get_global_explanation_plot( detail="Internal database error", ) from e - return plot + return normalize_artifacts(plot) @router.post("/global", status_code=status.HTTP_201_CREATED) @@ -441,7 +444,8 @@ async def get_local_explanation_plot( Returns ------- List[dict] - A JSON with the explanation plot. + A list of artifact dicts (``{"type", "payload", "title"}``) with the + explanation plots, typically one per explained instance. Raises ------ @@ -451,6 +455,8 @@ async def get_local_explanation_plot( """ import pickle + from DashAI.back.core.artifacts import normalize_artifacts + with session_factory() as db: try: local_explainer = db.scalars( @@ -481,7 +487,7 @@ async def get_local_explanation_plot( detail="Internal database error", ) from e - return plots + return normalize_artifacts(plots) @router.post("/local", status_code=status.HTTP_201_CREATED) diff --git a/DashAI/back/explainability/explainers/kernel_shap.py b/DashAI/back/explainability/explainers/kernel_shap.py index a02d6d230..b3b7587b8 100644 --- a/DashAI/back/explainability/explainers/kernel_shap.py +++ b/DashAI/back/explainability/explainers/kernel_shap.py @@ -1,3 +1,6 @@ +from typing import List, Optional + +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact from DashAI.back.core.schema_fields import ( BaseSchema, bool_field, @@ -424,7 +427,12 @@ def explain_instance( return explanation def _create_plot( - self, data, base_value: float, y_pred_pbb: float, y_pred_name: str + self, + data, + base_value: float, + y_pred_pbb: float, + y_pred_name: str, + title: Optional[str] = None, ): """Helper method to create the explanation plot using plotly. @@ -438,15 +446,16 @@ def _create_plot( predicted probability. y_pred_name name of the predicted class. + title: Optional[str] + title of the resulting artifact. - Returns: - JSON - JSON containing the information of the explanation plot - to be rendered. + Returns + ------- + PlotlyArtifact + The plotly artifact of the explanation plot for one instance. """ # Lazy imports import numpy as np - import plotly import plotly.graph_objs as go x = data["shap_values"].to_numpy() @@ -509,20 +518,21 @@ def _create_plot( y=-0.27, ) - return plotly.io.to_json(fig) + return PlotlyArtifact(payload=fig, title=title) - def plot(self, explanation: list[dict]): - """Method to create the explanation plot using plotly. + def plot(self, explanation: dict) -> List[Artifact]: + """Method to create the explanation plots using plotly. Parameters ---------- - explanation: dict - dictionary with the explanation generated by the explainer. + explanation : dict + Dictionary with the explanation generated by the explainer. - Returns: - List[dict] - list of JSONs containing the information of the explanation plot - to be rendered. + Returns + ------- + List[Artifact] + A list with one plotly artifact per explained instance; the + artifact titles ("Instance 1", ...) identify each instance. """ exp = explanation.copy() @@ -541,7 +551,7 @@ def plot(self, explanation: list[dict]): feats = np.asarray(feature_names, dtype=str).reshape(-1) plots = [] - for i in exp: + for instance_number, i in enumerate(exp, start=1): instance_values = exp[i]["instance_values"] model_prediction = exp[i]["model_prediction"] y_pred_class = int(np.argmax(model_prediction)) @@ -641,7 +651,13 @@ def plot(self, explanation: list[dict]): else: base_value = float(base_arr[y_pred_class]) - plot = self._create_plot(data, base_value, y_pred_pbb, y_pred_name) + plot = self._create_plot( + data, + base_value, + y_pred_pbb, + y_pred_name, + title=f"Instance {instance_number}", + ) plots.append(plot) return plots diff --git a/DashAI/back/explainability/explainers/partial_dependence.py b/DashAI/back/explainability/explainers/partial_dependence.py index 89079e38c..bfaf50d91 100644 --- a/DashAI/back/explainability/explainers/partial_dependence.py +++ b/DashAI/back/explainability/explainers/partial_dependence.py @@ -1,5 +1,6 @@ from typing import List +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact from DashAI.back.core.schema_fields import ( BaseSchema, float_field, @@ -243,21 +244,22 @@ def explain(self, dataset): return explanation - def _create_plot(self, data: List[object]) -> List[dict]: + def _create_plot(self, data: List[object]) -> List[Artifact]: """Helper method to create the explanation plot using plotly. Parameters ---------- - data: List - dictionary with the explanation generated by the explainer. - - Returns: - List[dict] - list of JSON containing the information of the explanation plot - to be rendered. + data : List + Per-feature DataFrames with the explanation generated by the + explainer. + + Returns + ------- + List[Artifact] + A single-element list with the plotly artifact of the + explanation plot. """ # Lazy imports - import plotly import plotly.express as px fig = px.line( @@ -313,20 +315,21 @@ def _create_plot(self, data: List[object]) -> List[dict]: y=-0.35, ) - return [plotly.io.to_json(fig)] + return [PlotlyArtifact(payload=fig, title="Partial Dependence")] - def plot(self, explanation: dict) -> List[dict]: + def plot(self, explanation: dict) -> List[Artifact]: """Method to create the explanation plot. Parameters ---------- - explanation: dict - dictionary with the explanation generated by the explainer. - - Returns: - List[dict] - list of JSONs containing the information of the explanation plot - to be rendered. + explanation : dict + Dictionary with the explanation generated by the explainer. + + Returns + ------- + List[Artifact] + A single-element list with the plotly artifact of the + explanation plot. """ # Lazy import import pandas as pd diff --git a/DashAI/back/explainability/explainers/permutation_feature_importance.py b/DashAI/back/explainability/explainers/permutation_feature_importance.py index 15d0bfaba..ab5d655c2 100644 --- a/DashAI/back/explainability/explainers/permutation_feature_importance.py +++ b/DashAI/back/explainability/explainers/permutation_feature_importance.py @@ -1,5 +1,6 @@ from typing import Dict, List, Union +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact from DashAI.back.core.schema_fields import ( BaseSchema, enum_field, @@ -530,12 +531,11 @@ def _create_plot(self, data, n_features: int): Returns ------- - list of str - A single-element list containing the Plotly figure serialised to - JSON via ``plotly.io.to_json``. + List[Artifact] + A single-element list with the plotly artifact of the + explanation plot. """ # Lazy imports - import plotly import plotly.express as px fig = px.bar( @@ -584,9 +584,9 @@ def _create_plot(self, data, n_features: int): ], ) - return [plotly.io.to_json(fig)] + return [PlotlyArtifact(payload=fig, title="Permutation Feature Importance")] - def plot(self, explanation: dict) -> List[dict]: + def plot(self, explanation: dict) -> List[Artifact]: """Create a Plotly bar chart from a feature importance explanation dict. Parameters @@ -597,9 +597,9 @@ def plot(self, explanation: dict) -> List[dict]: Returns ------- - list of str - A single-element list containing the Plotly figure serialised to - JSON (passed through :meth:`_create_plot`). + List[Artifact] + A single-element list with the plotly artifact of the + explanation plot (built by :meth:`_create_plot`). """ n_features = 10 # Lazy import diff --git a/DashAI/back/explainability/global_explainer.py b/DashAI/back/explainability/global_explainer.py index 90dddf00d..5f7b02e4f 100644 --- a/DashAI/back/explainability/global_explainer.py +++ b/DashAI/back/explainability/global_explainer.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Final, List, Tuple from DashAI.back.config_object import ConfigObject +from DashAI.back.core.artifacts import Artifact from DashAI.back.models.base_model import BaseModel if TYPE_CHECKING: @@ -18,8 +19,8 @@ class BaseGlobalExplainer(ConfigObject, ABC): partial dependence curves, or aggregate attribution scores. All concrete global explainers must implement :meth:`explain` (compute the - explanation from a dataset) and :meth:`plot` (serialise the explanation as - Plotly figures for the frontend). + explanation from a dataset) and :meth:`plot` (turn the explanation into + renderable artifacts for the frontend). """ TYPE: Final[str] = "GlobalExplainer" @@ -64,12 +65,12 @@ def explain(self, dataset: Tuple["DatasetDict", "DatasetDict"]) -> dict: raise NotImplementedError @abstractmethod - def plot(self, explanation: dict) -> List[dict]: - """Generate serialised plots from a previously computed explanation. + def plot(self, explanation: dict) -> List[Artifact]: + """Generate renderable artifacts from a previously computed explanation. Concrete implementations must convert the explanation dictionary - returned by :meth:`explain` into one or more serialised plot objects - that can be rendered on the frontend. + returned by :meth:`explain` into one or more typed artifacts that + can be rendered on the frontend. Parameters ---------- @@ -78,10 +79,10 @@ def plot(self, explanation: dict) -> List[dict]: Returns ------- - List[dict] - A list of serialised plot objects. Each element is a JSON string - (produced by ``plotly.io.to_json``) representing a single Plotly - figure. + List[Artifact] + A list of artifacts (:class:`PlotlyArtifact`, + :class:`TableArtifact`, :class:`TextArtifact` or + :class:`ImageArtifact`) describing the explanation. Raises ------ diff --git a/DashAI/back/explainability/local_explainer.py b/DashAI/back/explainability/local_explainer.py index 79409dafe..7432ec76e 100644 --- a/DashAI/back/explainability/local_explainer.py +++ b/DashAI/back/explainability/local_explainer.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Final, List, Tuple from DashAI.back.config_object import ConfigObject +from DashAI.back.core.artifacts import Artifact from DashAI.back.models.base_model import BaseModel if TYPE_CHECKING: @@ -19,7 +20,7 @@ class BaseLocalExplainer(ConfigObject, ABC): Concrete implementations must provide :meth:`fit` (prepare background data or internal state), :meth:`explain_instance` (compute per-instance attributions), - and :meth:`plot` (serialise explanations as Plotly figures). + and :meth:`plot` (turn explanations into renderable artifacts). """ TYPE: Final[str] = "LocalExplainer" @@ -87,12 +88,12 @@ def explain_instance(self, instances: "DatasetDict") -> dict: raise NotImplementedError @abstractmethod - def plot(self, explanation: dict) -> List[dict]: - """Generate serialised plots from a previously computed explanation. + def plot(self, explanation: dict) -> List[Artifact]: + """Generate renderable artifacts from a previously computed explanation. Concrete implementations must convert the explanation dictionary - returned by :meth:`explain_instance` into one or more serialised plot - objects that can be rendered on the frontend. + returned by :meth:`explain_instance` into one or more typed artifacts + that can be rendered on the frontend. Parameters ---------- @@ -101,10 +102,11 @@ def plot(self, explanation: dict) -> List[dict]: Returns ------- - List[dict] - A list of serialised plot objects. Each element is a JSON string - (produced by ``plotly.io.to_json``) representing a single Plotly - figure. + List[Artifact] + A list of artifacts (:class:`PlotlyArtifact`, + :class:`TableArtifact`, :class:`TextArtifact` or + :class:`ImageArtifact`) describing the explanation, typically + one per explained instance. Raises ------ diff --git a/DashAI/back/job/explainer_job.py b/DashAI/back/job/explainer_job.py index 4863a59f0..0e72adf74 100644 --- a/DashAI/back/job/explainer_job.py +++ b/DashAI/back/job/explainer_job.py @@ -127,13 +127,15 @@ def _generate_global_explanation( from kink import di + from DashAI.back.core.artifacts import normalize_artifacts + explainer_id: int = self.kwargs["explainer_id"] session_factory = di["session_factory"] config = di["config"] with session_factory() as db: try: explanation = explainer.explain(dataset) - plot = explainer.plot(explanation) + plot = normalize_artifacts(explainer.plot(explanation)) except Exception as e: log.exception(e) raise JobError( @@ -184,6 +186,7 @@ def _generate_local_explanation( from datasets import DatasetDict from kink import di + from DashAI.back.core.artifacts import normalize_artifacts from DashAI.back.dataloaders.classes.dashai_dataset import ( load_dataset, prepare_for_model_session, @@ -271,14 +274,14 @@ def _generate_local_explanation( ) from e try: explanation = explainer.explain_instance(X) - plots = explainer.plot(explanation) + plots = normalize_artifacts(explainer.plot(explanation)) except Exception as e: log.exception(e) raise JobError( "Failed to generate the explanation", ) from e try: - explanation_filename = f"local_explanation_{explainer_id}.json" + explanation_filename = f"local_explanation_{explainer_id}.pickle" explanation_path = os.path.join( config["EXPLANATIONS_PATH"], explanation_filename ) diff --git a/tests/back/explainers/test_explainers.py b/tests/back/explainers/test_explainers.py index 86408cc18..9bcf3e145 100644 --- a/tests/back/explainers/test_explainers.py +++ b/tests/back/explainers/test_explainers.py @@ -1,9 +1,11 @@ import copy +import json import pyarrow as pa import pytest from datasets import DatasetDict +from DashAI.back.core.artifacts import PlotlyArtifact from DashAI.back.dataloaders.classes.csv_dataloader import CSVDataLoader from DashAI.back.dataloaders.classes.dashai_dataset import ( DashAIDataset, @@ -129,6 +131,10 @@ def test_partial_dependence(trained_model: BaseModel, dataset): assert len(explanation) == len(INPUT_COLUMNS) assert len(plot) == 1 + assert isinstance(plot[0], PlotlyArtifact) + artifact_dict = plot[0].to_dict() + assert artifact_dict["type"] == "plotly" + json.loads(artifact_dict["payload"]) for feature_key in explanation.values(): assert "grid_values" in feature_key @@ -164,6 +170,10 @@ def test_permutation_feature_importance(trained_model: BaseModel, dataset: Datas for key in ["features", "importances_mean", "importances_std"] ) assert len(plot) == 1 + assert isinstance(plot[0], PlotlyArtifact) + artifact_dict = plot[0].to_dict() + assert artifact_dict["type"] == "plotly" + json.loads(artifact_dict["payload"]) for values in explanation.values(): assert len(values) == len(INPUT_COLUMNS) From c7fb3bc4a7a1783615388a7c8781f3c9f4819df5 Mon Sep 17 00:00:00 2001 From: Irozuku Date: Fri, 10 Jul 2026 15:46:55 -0400 Subject: [PATCH 4/8] feat: return artifacts from explorer results --- DashAI/back/api/api_v1/endpoints/explorers.py | 4 +- DashAI/back/exploration/base_explorer.py | 13 ++-- DashAI/back/exploration/explorers/box_plot.py | 23 +++---- .../back/exploration/explorers/corr_matrix.py | 61 +++++++++++-------- .../back/exploration/explorers/cov_matrix.py | 61 +++++++++++-------- .../exploration/explorers/density_heatmap.py | 17 +++--- .../explorers/describe_explorer.py | 47 +++++++------- .../back/exploration/explorers/ecdf_plot.py | 21 +++---- .../exploration/explorers/histogram_plot.py | 21 +++---- .../exploration/explorers/multibox_plot.py | 21 +++---- .../explorers/parallel_categories.py | 21 +++---- .../explorers/parallel_cordinates.py | 21 +++---- .../exploration/explorers/row_explorer.py | 46 +++++++------- .../exploration/explorers/scatter_matrix.py | 21 +++---- .../exploration/explorers/scatter_plot.py | 21 +++---- .../back/exploration/explorers/wordcloud.py | 34 ++++------- 16 files changed, 211 insertions(+), 242 deletions(-) diff --git a/DashAI/back/api/api_v1/endpoints/explorers.py b/DashAI/back/api/api_v1/endpoints/explorers.py index 45eedbf25..a536cc97d 100644 --- a/DashAI/back/api/api_v1/endpoints/explorers.py +++ b/DashAI/back/api/api_v1/endpoints/explorers.py @@ -12,6 +12,7 @@ ExplorerCreate, ExplorerResultsOptions, ) +from DashAI.back.core.artifacts import normalize_artifacts from DashAI.back.core.enums.status import ExplorerStatus from DashAI.back.dependencies.database.models import Dataset, Explorer, Notebook @@ -328,7 +329,8 @@ async def get_explorer_results( detail="Error while getting explorer results", ) from e - return results + # Upgrade legacy {"data", "type", "config"} results from plugin explorers + return normalize_artifacts(results) @router.put("/{explorer_id}/results/") diff --git a/DashAI/back/exploration/base_explorer.py b/DashAI/back/exploration/base_explorer.py index ed3e9c926..cc27b071b 100644 --- a/DashAI/back/exploration/base_explorer.py +++ b/DashAI/back/exploration/base_explorer.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Any, Dict, Final, List from DashAI.back.config_object import ConfigObject +from DashAI.back.core.artifacts import Artifact from DashAI.back.core.schema_fields import BaseSchema from DashAI.back.dependencies.database.models import Explorer, Notebook from DashAI.back.static.icons import Icon @@ -276,7 +277,7 @@ def save_notebook( @abstractmethod def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load a previously saved exploration result and return it for the frontend. Parameters @@ -289,9 +290,11 @@ def get_results( Returns ------- - Dict[str, Any] - A dict with keys ``"data"`` (serialized result), - ``"type"`` (result type string, e.g. ``"plotly_json"``), and - ``"config"`` (frontend rendering config). + List[Artifact] + A list of artifacts (:class:`PlotlyArtifact`, + :class:`TableArtifact`, :class:`TextArtifact` or + :class:`ImageArtifact`) describing the exploration result. + Legacy explorers returning the old ``{"data", "type", "config"}`` + dict are upgraded by ``normalize_artifacts`` at the API layer. """ raise NotImplementedError diff --git a/DashAI/back/exploration/explorers/box_plot.py b/DashAI/back/exploration/explorers/box_plot.py index 611232e57..602531de5 100644 --- a/DashAI/back/exploration/explorers/box_plot.py +++ b/DashAI/back/exploration/explorers/box_plot.py @@ -1,5 +1,6 @@ -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any, Dict, List +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact from DashAI.back.core.schema_fields import bool_field, enum_field, schema_field from DashAI.back.core.utils import MultilingualString from DashAI.back.dependencies.database.models import Explorer, Notebook @@ -246,7 +247,7 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved box plot for the frontend. Parameters @@ -258,17 +259,11 @@ def get_results( Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (JSON-serialized - Plotly figure), ``"type"`` (``"plotly_json"``), and - ``"config"`` (empty dict). + List[Artifact] + A single-element list with the plotly artifact of the saved + figure. """ - from plotly.io import read_json + with open(exploration_path, "r", encoding="utf-8") as f: + result = f.read() - resultType = "plotly_json" - config = {} - - result = read_json(exploration_path) - result = result.to_json() - - return {"data": result, "type": resultType, "config": config} + return [PlotlyArtifact(payload=result)] diff --git a/DashAI/back/exploration/explorers/corr_matrix.py b/DashAI/back/exploration/explorers/corr_matrix.py index 2e9b00b33..cb115c2a3 100644 --- a/DashAI/back/exploration/explorers/corr_matrix.py +++ b/DashAI/back/exploration/explorers/corr_matrix.py @@ -1,6 +1,12 @@ from enum import Enum -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any, Dict, List +from DashAI.back.core.artifacts import ( + Artifact, + PlotlyArtifact, + TableArtifact, + TablePayload, +) from DashAI.back.core.schema_fields import ( bool_field, enum_field, @@ -326,46 +332,49 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved correlation result for the frontend. When ``self.plot`` is ``True``, reads the raw Plotly JSON string from - disk. Otherwise reads the JSON file as a pandas DataFrame and converts - it to a nested dictionary. + disk. Otherwise reads the JSON file as a pandas DataFrame, transposes + it, and returns it as a table artifact with the column names in an + index column. Parameters ---------- exploration_path : str - Path to the JSON file saved by - ``save_notebook``. + Path to the JSON file saved by ``save_notebook``. options : Dict[str, Any] - Rendering options from the frontend - (unused). + Rendering options from the frontend (unused). Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (Plotly JSON string - when plotting, or nested dict of the correlation matrix otherwise), - ``"type"`` (``"plotly_json"`` when plotting, or ``"tabular"`` - otherwise), and ``"config"`` (empty dict when plotting, or - ``{"orient": "dict"}`` otherwise). + List[Artifact] + A single-element list with the Plotly artifact of the heatmap + when ``self.plot`` is ``True``, or the table artifact of the + correlation matrix otherwise. """ - from pathlib import Path - - import numpy as np - import pandas as pd - if self.plot: - resultType = "plotly_json" with open(exploration_path, "r", encoding="utf-8") as f: result = f.read() - return {"type": resultType, "data": result, "config": {}} + return [PlotlyArtifact(payload=result)] - resultType = "tabular" - config = {"orient": "dict"} + from pathlib import Path - path = Path(exploration_path) + import numpy as np + import pandas as pd - result = pd.read_json(path).replace({np.nan: None}).T.to_dict(orient="dict") - return {"type": resultType, "data": result, "config": config} + matrix = pd.read_json(Path(exploration_path)).replace({np.nan: None}).T + return [ + TableArtifact( + payload=TablePayload( + columns=["index", *matrix.columns.astype(str)], + rows=[ + [str(index), *row] + for index, row in zip( + matrix.index, matrix.to_numpy().tolist(), strict=True + ) + ], + ) + ) + ] diff --git a/DashAI/back/exploration/explorers/cov_matrix.py b/DashAI/back/exploration/explorers/cov_matrix.py index 69282628e..5a2768e49 100644 --- a/DashAI/back/exploration/explorers/cov_matrix.py +++ b/DashAI/back/exploration/explorers/cov_matrix.py @@ -1,5 +1,11 @@ -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any, Dict, List +from DashAI.back.core.artifacts import ( + Artifact, + PlotlyArtifact, + TableArtifact, + TablePayload, +) from DashAI.back.core.schema_fields import bool_field, int_field, schema_field from DashAI.back.core.utils import MultilingualString from DashAI.back.dependencies.database.models import Explorer, Notebook @@ -317,46 +323,49 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved covariance result for the frontend. When ``self.plot`` is ``True``, reads the raw Plotly JSON string from - disk. Otherwise reads the JSON file as a pandas DataFrame and converts - it to a nested dictionary. + disk. Otherwise reads the JSON file as a pandas DataFrame, transposes + it, and returns it as a table artifact with the column names in an + index column. Parameters ---------- exploration_path : str - Path to the JSON file saved by - ``save_notebook``. + Path to the JSON file saved by ``save_notebook``. options : Dict[str, Any] - Rendering options from the frontend - (unused). + Rendering options from the frontend (unused). Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (Plotly JSON string - when plotting, or nested dict of the covariance matrix - otherwise), ``"type"`` (``"plotly_json"`` when plotting, or - ``"tabular"`` otherwise), and ``"config"`` (empty dict when - plotting, or ``{"orient": "dict"}`` otherwise). + List[Artifact] + A single-element list with the Plotly artifact of the heatmap + when ``self.plot`` is ``True``, or the table artifact of the + covariance matrix otherwise. """ - from pathlib import Path - - import numpy as np - import pandas as pd - if self.plot: - resultType = "plotly_json" with open(exploration_path, "r", encoding="utf-8") as f: result = f.read() - return {"type": resultType, "data": result, "config": {}} + return [PlotlyArtifact(payload=result)] - resultType = "tabular" - config = {"orient": "dict"} + from pathlib import Path - path = Path(exploration_path) + import numpy as np + import pandas as pd - result = pd.read_json(path).replace({np.nan: None}).T.to_dict(orient="dict") - return {"type": resultType, "data": result, "config": config} + matrix = pd.read_json(Path(exploration_path)).replace({np.nan: None}).T + return [ + TableArtifact( + payload=TablePayload( + columns=["index", *matrix.columns.astype(str)], + rows=[ + [str(index), *row] + for index, row in zip( + matrix.index, matrix.to_numpy().tolist(), strict=True + ) + ], + ) + ) + ] diff --git a/DashAI/back/exploration/explorers/density_heatmap.py b/DashAI/back/exploration/explorers/density_heatmap.py index b9d0fd3f5..8d874fa5f 100644 --- a/DashAI/back/exploration/explorers/density_heatmap.py +++ b/DashAI/back/exploration/explorers/density_heatmap.py @@ -1,5 +1,6 @@ -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any, Dict, List +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact from DashAI.back.core.schema_fields import int_field, none_type, schema_field from DashAI.back.core.utils import MultilingualString from DashAI.back.dependencies.database.models import Explorer, Notebook @@ -197,7 +198,7 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved density heatmap for the frontend. Parameters @@ -209,15 +210,11 @@ def get_results( Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (JSON-serialized - Plotly figure), ``"type"`` (``"plotly_json"``), and - ``"config"`` (empty dict). + List[Artifact] + A single-element list with the plotly artifact of the saved + figure. """ - resultType = "plotly_json" - config = {} - with open(exploration_path, "r", encoding="utf-8") as f: result = f.read() - return {"data": result, "type": resultType, "config": config} + return [PlotlyArtifact(payload=result)] diff --git a/DashAI/back/exploration/explorers/describe_explorer.py b/DashAI/back/exploration/explorers/describe_explorer.py index 17344fa6f..d9c4874ef 100644 --- a/DashAI/back/exploration/explorers/describe_explorer.py +++ b/DashAI/back/exploration/explorers/describe_explorer.py @@ -1,5 +1,6 @@ -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any, Dict, List +from DashAI.back.core.artifacts import Artifact, TableArtifact, TablePayload from DashAI.back.core.schema_fields import ( enum_field, none_type, @@ -327,43 +328,41 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved statistical summary for the frontend. Reads the JSON file written by ``save_notebook``, transposes the - DataFrame so that statistics are keys, and converts it to a nested - dictionary. + DataFrame so that statistics become columns, and returns it as a + table artifact with the feature names in an index column. Parameters ---------- exploration_path : str - Path to the JSON file saved by - ``save_notebook``. + Path to the JSON file saved by ``save_notebook``. options : Dict[str, Any] - Rendering options from the frontend. - Supports ``"orientation"`` (str, default ``"dict"``), which is - forwarded to ``pandas.DataFrame.to_dict``. + Rendering options from the frontend (unused). Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (nested dict of - the transposed describe output in the requested orientation), - ``"type"`` (``"tabular"``), and ``"config"`` (dict containing - ``{"orient": }``). + List[Artifact] + A single-element list with the table artifact of the summary. """ from pathlib import Path import numpy as np import pandas as pd - resultType = "tabular" - orientation = options.get("orientation", "dict") - config = {"orient": orientation} - - path = Path(exploration_path) - - result = ( - pd.read_json(path).replace({np.nan: None}).T.to_dict(orient=orientation) - ) - return {"type": resultType, "data": result, "config": config} + summary = pd.read_json(Path(exploration_path)).replace({np.nan: None}).T + return [ + TableArtifact( + payload=TablePayload( + columns=["index", *summary.columns.astype(str)], + rows=[ + [str(index), *row] + for index, row in zip( + summary.index, summary.to_numpy().tolist(), strict=True + ) + ], + ) + ) + ] diff --git a/DashAI/back/exploration/explorers/ecdf_plot.py b/DashAI/back/exploration/explorers/ecdf_plot.py index 4f4d6941d..68c1c6ee0 100644 --- a/DashAI/back/exploration/explorers/ecdf_plot.py +++ b/DashAI/back/exploration/explorers/ecdf_plot.py @@ -1,6 +1,7 @@ from enum import Enum from typing import TYPE_CHECKING, Any, Dict, List, Union +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact from DashAI.back.core.schema_fields import ( enum_field, int_field, @@ -348,7 +349,7 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved ECDF plot for the frontend. Parameters @@ -360,17 +361,11 @@ def get_results( Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (JSON-serialized - Plotly figure), ``"type"`` (``"plotly_json"``), and - ``"config"`` (empty dict). + List[Artifact] + A single-element list with the plotly artifact of the saved + figure. """ - import plotly.io as pio + with open(exploration_path, "r", encoding="utf-8") as f: + result = f.read() - resultType = "plotly_json" - config = {} - - result = pio.read_json(exploration_path) - result = result.to_json() - - return {"data": result, "type": resultType, "config": config} + return [PlotlyArtifact(payload=result)] diff --git a/DashAI/back/exploration/explorers/histogram_plot.py b/DashAI/back/exploration/explorers/histogram_plot.py index 800a12001..d04caf1c5 100644 --- a/DashAI/back/exploration/explorers/histogram_plot.py +++ b/DashAI/back/exploration/explorers/histogram_plot.py @@ -1,6 +1,7 @@ from enum import Enum from typing import TYPE_CHECKING, Any, Dict, List, Union +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact from DashAI.back.core.schema_fields import ( enum_field, int_field, @@ -360,7 +361,7 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved histogram for the frontend. Parameters @@ -372,17 +373,11 @@ def get_results( Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (JSON-serialized - Plotly figure), ``"type"`` (``"plotly_json"``), and - ``"config"`` (empty dict). + List[Artifact] + A single-element list with the plotly artifact of the saved + figure. """ - import plotly.io as pio + with open(exploration_path, "r", encoding="utf-8") as f: + result = f.read() - resultType = "plotly_json" - config = {} - - result = pio.read_json(exploration_path) - result = result.to_json() - - return {"data": result, "type": resultType, "config": config} + return [PlotlyArtifact(payload=result)] diff --git a/DashAI/back/exploration/explorers/multibox_plot.py b/DashAI/back/exploration/explorers/multibox_plot.py index 0bbe310fb..8a65dc804 100644 --- a/DashAI/back/exploration/explorers/multibox_plot.py +++ b/DashAI/back/exploration/explorers/multibox_plot.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Any, Dict, List +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact from DashAI.back.core.schema_fields import ( bool_field, enum_field, @@ -337,7 +338,7 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved multi-column box plot for the frontend. Parameters @@ -349,17 +350,11 @@ def get_results( Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (JSON-serialized - Plotly figure), ``"type"`` (``"plotly_json"``), and - ``"config"`` (empty dict). + List[Artifact] + A single-element list with the plotly artifact of the saved + figure. """ - from plotly.io import read_json + with open(exploration_path, "r", encoding="utf-8") as f: + result = f.read() - resultType = "plotly_json" - config = {} - - result = read_json(exploration_path) - result = result.to_json() - - return {"data": result, "type": resultType, "config": config} + return [PlotlyArtifact(payload=result)] diff --git a/DashAI/back/exploration/explorers/parallel_categories.py b/DashAI/back/exploration/explorers/parallel_categories.py index 38db20290..beb2a24c6 100644 --- a/DashAI/back/exploration/explorers/parallel_categories.py +++ b/DashAI/back/exploration/explorers/parallel_categories.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Any, Dict, List, Union +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact from DashAI.back.core.schema_fields import ( int_field, none_type, @@ -220,7 +221,7 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved parallel categories plot for the frontend. Parameters @@ -232,17 +233,11 @@ def get_results( Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (JSON-serialized - Plotly figure), ``"type"`` (``"plotly_json"``), and - ``"config"`` (empty dict). + List[Artifact] + A single-element list with the plotly artifact of the saved + figure. """ - import plotly.io as pio + with open(exploration_path, "r", encoding="utf-8") as f: + result = f.read() - resultType = "plotly_json" - config = {} - - result = pio.read_json(exploration_path) - result = result.to_json() - - return {"data": result, "type": resultType, "config": config} + return [PlotlyArtifact(payload=result)] diff --git a/DashAI/back/exploration/explorers/parallel_cordinates.py b/DashAI/back/exploration/explorers/parallel_cordinates.py index 9d93a783e..2428da9e2 100644 --- a/DashAI/back/exploration/explorers/parallel_cordinates.py +++ b/DashAI/back/exploration/explorers/parallel_cordinates.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Any, Dict, List, Union +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact from DashAI.back.core.schema_fields import ( int_field, none_type, @@ -222,7 +223,7 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved parallel coordinates plot for the frontend. Parameters @@ -234,17 +235,11 @@ def get_results( Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (JSON-serialized - Plotly figure), ``"type"`` (``"plotly_json"``), and - ``"config"`` (empty dict). + List[Artifact] + A single-element list with the plotly artifact of the saved + figure. """ - import plotly.io as pio + with open(exploration_path, "r", encoding="utf-8") as f: + result = f.read() - resultType = "plotly_json" - config = {} - - result = pio.read_json(exploration_path) - result = result.to_json() - - return {"data": result, "type": resultType, "config": config} + return [PlotlyArtifact(payload=result)] diff --git a/DashAI/back/exploration/explorers/row_explorer.py b/DashAI/back/exploration/explorers/row_explorer.py index 051dfd541..35e39cdfd 100644 --- a/DashAI/back/exploration/explorers/row_explorer.py +++ b/DashAI/back/exploration/explorers/row_explorer.py @@ -1,5 +1,6 @@ -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any, Dict, List +from DashAI.back.core.artifacts import Artifact, TableArtifact, TablePayload from DashAI.back.core.schema_fields import bool_field, int_field, schema_field from DashAI.back.core.utils import MultilingualString from DashAI.back.dependencies.database.models import Explorer, Notebook @@ -237,40 +238,41 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved row sample for the frontend. - Reads the JSON file written by ``save_notebook`` and converts it to a - nested dictionary for tabular display. + Reads the JSON file written by ``save_notebook`` and returns it as a + table artifact with the row labels in an index column. Parameters ---------- exploration_path : str - Path to the JSON file saved by - ``save_notebook``. + Path to the JSON file saved by ``save_notebook``. options : Dict[str, Any] - Rendering options from the frontend. - Supports ``"orientation"`` (str, default ``"dict"``), which is - forwarded to ``pandas.DataFrame.to_dict``. + Rendering options from the frontend (unused). Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (nested dict of - the sampled rows in the requested orientation), ``"type"`` - (``"tabular"``), and ``"config"`` (dict containing - ``{"orient": }``). + List[Artifact] + A single-element list with the table artifact of the sampled + rows. """ from pathlib import Path import numpy as np import pandas as pd - resultType = "tabular" - orientation = options.get("orientation", "dict") - config = {"orient": orientation} - - path = Path(exploration_path) - - result = pd.read_json(path).replace({np.nan: None}).to_dict(orient=orientation) - return {"type": resultType, "data": result, "config": config} + sample = pd.read_json(Path(exploration_path)).replace({np.nan: None}) + return [ + TableArtifact( + payload=TablePayload( + columns=["index", *sample.columns.astype(str)], + rows=[ + [str(index), *row] + for index, row in zip( + sample.index, sample.to_numpy().tolist(), strict=True + ) + ], + ) + ) + ] diff --git a/DashAI/back/exploration/explorers/scatter_matrix.py b/DashAI/back/exploration/explorers/scatter_matrix.py index 1569abc21..0ed3ef6f4 100644 --- a/DashAI/back/exploration/explorers/scatter_matrix.py +++ b/DashAI/back/exploration/explorers/scatter_matrix.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Any, Dict, List +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact from DashAI.back.core.schema_fields import ( int_field, none_type, @@ -270,7 +271,7 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved scatter matrix for the frontend. Parameters @@ -282,17 +283,11 @@ def get_results( Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (JSON-serialized - Plotly figure), ``"type"`` (``"plotly_json"``), and - ``"config"`` (empty dict). + List[Artifact] + A single-element list with the plotly artifact of the saved + figure. """ - import plotly.io as pio + with open(exploration_path, "r", encoding="utf-8") as f: + result = f.read() - resultType = "plotly_json" - config = {} - - result = pio.read_json(exploration_path) - result = result.to_json() - - return {"data": result, "type": resultType, "config": config} + return [PlotlyArtifact(payload=result)] diff --git a/DashAI/back/exploration/explorers/scatter_plot.py b/DashAI/back/exploration/explorers/scatter_plot.py index 4c79e5240..afdc0d505 100644 --- a/DashAI/back/exploration/explorers/scatter_plot.py +++ b/DashAI/back/exploration/explorers/scatter_plot.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Any, Dict, List +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact from DashAI.back.core.schema_fields import ( int_field, none_type, @@ -298,7 +299,7 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved scatter plot for the frontend. Parameters @@ -310,17 +311,11 @@ def get_results( Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (JSON-serialized - Plotly figure), ``"type"`` (``"plotly_json"``), and - ``"config"`` (empty dict). + List[Artifact] + A single-element list with the plotly artifact of the saved + figure. """ - from plotly.io import read_json + with open(exploration_path, "r", encoding="utf-8") as f: + result = f.read() - resultType = "plotly_json" - config = {} - - result = read_json(exploration_path) - result = result.to_json() - - return {"data": result, "type": resultType, "config": config} + return [PlotlyArtifact(payload=result)] diff --git a/DashAI/back/exploration/explorers/wordcloud.py b/DashAI/back/exploration/explorers/wordcloud.py index 7f8f76724..8d38a40d6 100644 --- a/DashAI/back/exploration/explorers/wordcloud.py +++ b/DashAI/back/exploration/explorers/wordcloud.py @@ -1,5 +1,6 @@ -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any, Dict, List +from DashAI.back.core.artifacts import Artifact, ImageArtifact, ImagePayload from DashAI.back.core.schema_fields import ( int_field, none_type, @@ -222,39 +223,26 @@ def save_notebook( def get_results( self, exploration_path: str, options: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> List[Artifact]: """Load and return the saved word cloud image for the frontend. - Reads the PNG file written by ``save_notebook`` and encodes it as a - base64 string for transmission to the frontend. + Reads the PNG file written by ``save_notebook`` and wraps its bytes + in an image artifact. Parameters ---------- exploration_path : str - Path to the PNG file saved by - ``save_notebook``. + Path to the PNG file saved by ``save_notebook``. options : Dict[str, Any] - Rendering options from the frontend - (unused). + Rendering options from the frontend (unused). Returns ------- - Dict[str, Any] - Dictionary with keys ``"data"`` (base64-encoded - UTF-8 string of the PNG image), ``"type"`` - (``"image_base64"``), and ``"config"`` (empty dict). + List[Artifact] + A single-element list with the image artifact of the word + cloud. """ - import base64 - - resultType = "image_base64" - config = {} - - # Load image with open(exploration_path, "rb") as f: result = f.read() - # encode image to base64 - result = base64.b64encode(result).decode("utf-8") - - # Return image - return {"data": result, "type": resultType, "config": config} + return [ImageArtifact(payload=ImagePayload(data=result))] From ddad3f2851c358b241ee2be7bf8fe5eeb624731e Mon Sep 17 00:00:00 2001 From: Irozuku Date: Fri, 10 Jul 2026 16:04:39 -0400 Subject: [PATCH 5/8] feat: normalize pipeline exploration results as artifacts --- DashAI/back/api/api_v1/endpoints/pipelines.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DashAI/back/api/api_v1/endpoints/pipelines.py b/DashAI/back/api/api_v1/endpoints/pipelines.py index 5c2b8a210..b0671fdbf 100644 --- a/DashAI/back/api/api_v1/endpoints/pipelines.py +++ b/DashAI/back/api/api_v1/endpoints/pipelines.py @@ -12,6 +12,7 @@ ValidateNodeParams, ValidatePipelineParams, ) +from DashAI.back.core.artifacts import normalize_artifacts from DashAI.back.dependencies.database.models import Dataset, Pipeline from DashAI.back.pipeline.validator.nodes_definitions import NODES from DashAI.back.pipeline.validator.pipeline_validator import PipelineValidator @@ -280,7 +281,7 @@ async def get_pipeline_dataexploration_results( ) results[exploration_id] = { "exploration_type": exploration_type, - "results": result, + "results": normalize_artifacts(result), "name": name, } except Exception as e: From ec7399a760036b9ac8d28ab10076164ee1e40d30 Mon Sep 17 00:00:00 2001 From: Irozuku Date: Fri, 10 Jul 2026 16:06:25 -0400 Subject: [PATCH 6/8] feat: render typed artifacts in frontend --- DashAI/front/src/api/explainer.ts | 5 +- DashAI/front/src/api/explorer.ts | 5 +- .../components/explainers/ExplainersPlot.jsx | 85 ++++---- .../explorers/DetailTabs/Results.jsx | 194 ++---------------- .../notebooks/explorer/useExplorerResults.jsx | 179 +--------------- .../pipelines/results/ResultsExploration.jsx | 159 +++----------- .../components/shared/ArtifactRenderer.jsx | 148 +++++++++++++ DashAI/front/src/types/artifact.ts | 13 ++ DashAI/front/src/types/explorer.ts | 6 - .../src/utils/artifactVisualizerData.jsx | 124 +++++++++++ .../src/utils/i18n/locales/de/explainers.json | 1 + .../src/utils/i18n/locales/en/explainers.json | 1 + .../src/utils/i18n/locales/es/explainers.json | 1 + .../src/utils/i18n/locales/pt/explainers.json | 1 + .../src/utils/i18n/locales/zh/explainers.json | 1 + 15 files changed, 385 insertions(+), 538 deletions(-) create mode 100644 DashAI/front/src/components/shared/ArtifactRenderer.jsx create mode 100644 DashAI/front/src/types/artifact.ts create mode 100644 DashAI/front/src/utils/artifactVisualizerData.jsx diff --git a/DashAI/front/src/api/explainer.ts b/DashAI/front/src/api/explainer.ts index 1e9293350..a69583059 100644 --- a/DashAI/front/src/api/explainer.ts +++ b/DashAI/front/src/api/explainer.ts @@ -1,4 +1,5 @@ import api from "./api"; +import type { IArtifact } from "../types/artifact"; import type { IExplainer } from "../types/explainer"; export const getExplainers = async ( @@ -15,8 +16,8 @@ export const getExplainers = async ( export const getExplainerPlot = async ( explainerId: string = "", scope: string = "", -): Promise => { - const response = await api.get( +): Promise => { + const response = await api.get( `/v1/explainer/${scope}/plot/${explainerId}`, ); diff --git a/DashAI/front/src/api/explorer.ts b/DashAI/front/src/api/explorer.ts index 7c39c4844..9f5561083 100644 --- a/DashAI/front/src/api/explorer.ts +++ b/DashAI/front/src/api/explorer.ts @@ -1,5 +1,6 @@ import api from "./api"; -import type { IExplorer, IExplorerResults } from "../types/explorer"; +import type { IArtifact } from "../types/artifact"; +import type { IExplorer } from "../types/explorer"; const explorerEndpoint = "/v1/explorer"; @@ -103,7 +104,7 @@ export const deleteExplorer = async (explorerId: string): Promise => { export const getExplorerResults = async ( explorerId: number, options: object = {}, -): Promise => { +): Promise => { const data = { options }; const response = await api.post( `${explorerEndpoint}/${explorerId}/results/`, diff --git a/DashAI/front/src/components/explainers/ExplainersPlot.jsx b/DashAI/front/src/components/explainers/ExplainersPlot.jsx index 1c3ac555f..9a87e6048 100644 --- a/DashAI/front/src/components/explainers/ExplainersPlot.jsx +++ b/DashAI/front/src/components/explainers/ExplainersPlot.jsx @@ -1,4 +1,4 @@ -import { React, useEffect, useMemo, useState } from "react"; +import { React, useEffect, useState } from "react"; import { FormControl, InputLabel, @@ -7,55 +7,51 @@ import { CircularProgress, Box, } from "@mui/material"; -import { useTheme } from "@mui/material/styles"; -import Plot from "react-plotly.js"; import PropTypes from "prop-types"; import { useSnackbar } from "notistack"; import { getExplainerPlot as getExplainerPlotRequest } from "../../api/explainer"; import { useTranslation } from "react-i18next"; -import { applyThemeToLayout } from "../../utils/plotlyTheme"; +import ArtifactRenderer from "../shared/ArtifactRenderer"; + +/** + * Coerce the plot endpoint response into a list of typed artifacts. + * Legacy responses are lists of plotly JSON strings; they are wrapped as + * plotly artifacts. Typed dicts pass through untouched. + */ +function parseExplanationArtifacts(items) { + return items.map((item) => + typeof item === "string" + ? { type: "plotly", payload: item, title: null } + : item, + ); +} export default function ExplainersPlot({ explainer, scope }) { const { enqueueSnackbar } = useSnackbar(); - const theme = useTheme(); - const [explainersPlots, setExplainersPlots] = useState([]); - const [currentPlot, setCurrentPlot] = useState(0); + const [artifacts, setArtifacts] = useState([]); + const [currentArtifact, setCurrentArtifact] = useState(0); const [loading, setLoading] = useState(true); - const isLocal = scope === "local"; const { t } = useTranslation(["explainers"]); - const themedLayout = useMemo(() => { - if (!explainersPlots[currentPlot]) return {}; - return applyThemeToLayout(explainersPlots[currentPlot].layout, theme); - }, [explainersPlots, currentPlot, theme]); - function parseExplanationPlot(explanation) { - const formattedPlot = JSON.parse(JSON.stringify(explanation)); - return formattedPlot.map(JSON.parse); - } - const getExplainerPlot = async () => { setLoading(true); try { - const explainersPlots = await getExplainerPlotRequest( - explainer.id, - scope, - ); - if (!explainersPlots || explainersPlots.length === 0) { - setExplainersPlots([]); - setCurrentPlot(0); + const response = await getExplainerPlotRequest(explainer.id, scope); + if (!response || response.length === 0) { + setArtifacts([]); + setCurrentArtifact(0); enqueueSnackbar(t("explainers:error.noData"), { variant: "warning", }); } else { - const parsedExplainersPlot = parseExplanationPlot(explainersPlots); - setExplainersPlots(parsedExplainersPlot); - // Reset currentPlot when data updates to avoid stale index - setCurrentPlot(0); + setArtifacts(parseExplanationArtifacts(response)); + // Reset currentArtifact when data updates to avoid stale index + setCurrentArtifact(0); } } catch (error) { - setExplainersPlots([]); - setCurrentPlot(0); + setArtifacts([]); + setCurrentArtifact(0); enqueueSnackbar(t("explainers:error.fetchExplainers"), { variant: "error", }); @@ -92,37 +88,30 @@ export default function ExplainersPlot({ explainer, scope }) { p: 1, }} > - {!loading && isLocal && explainersPlots.length > 0 && ( + {!loading && artifacts.length > 1 && ( - Select an instance + + {t("explainers:label.selectInstance")} + )} {!loading && explainer.status === 3 ? ( - explainersPlots.length > 0 && explainersPlots[currentPlot] ? ( - + artifacts.length > 0 && artifacts[currentArtifact] ? ( + ) : ( {t("explainers:error.noData")} ) diff --git a/DashAI/front/src/components/explorations/explorers/DetailTabs/Results.jsx b/DashAI/front/src/components/explorations/explorers/DetailTabs/Results.jsx index dd71cbd7d..46b20fef2 100644 --- a/DashAI/front/src/components/explorations/explorers/DetailTabs/Results.jsx +++ b/DashAI/front/src/components/explorations/explorers/DetailTabs/Results.jsx @@ -1,147 +1,19 @@ import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; -import { Box, CircularProgress, Tooltip, Typography } from "@mui/material"; +import { Box, CircularProgress } from "@mui/material"; import { getExplorerResults } from "../../../../api/explorer"; +import { + artifactToVisualizerData, + visualizersKeys, +} from "../../../../utils/artifactVisualizerData"; import { TabularVisualizer, PlotlyJsonVisualizer, ImageVisualizer, } from "../../Visualizations"; -/** - * NullCell component to render null values in the tabular visualizer - * @param {Object} props - */ -function NullCell({}) { - const [hover, setHover] = useState(false); - return ( - setHover(true)} - onMouseLeave={() => setHover(false)} - > - - {hover ? "None" : "-"} - - - ); -} - -const visualizers = { - tabular: TabularVisualizer, - plotly_json: PlotlyJsonVisualizer, - image_base64: ImageVisualizer, - image_url: ImageVisualizer, -}; -const visualizersKeys = { - tabular: "tabular", - plotly_json: "plotly_json", - image_base64: "image_base64", - image_url: "image_url", -}; - -const ORIENTATIONS = { - dict: "dict", - records: "records", -}; - -/** - * Get the data from the orientation given. This function is used to transform the data - * from the explorer results to the format required by the tabular visualizer. - * @param {Object} data The data from the explorer results - * @param {String} orientation The orientation of the data - */ -const getDataFromOrientation = (data, orientation) => { - let res = { - columns: [], - rows: [], - }; - - if (orientation === ORIENTATIONS.records) { - throw new Error(`orientation ${orientation} not supported`); - } - - if (orientation === ORIENTATIONS.dict) { - // ‘dict’ (default) : dict like {column -> {index -> value}} - // Get the columns - const columns = Object.keys(data); - res.columns = [ - { - field: "id", - headerName: "Index", - renderCell: (params) => { - return ( - - {params.value} - - ); - }, - }, - ...columns.map((column) => { - return { - field: column, - headerName: column, - renderCell: (params) => { - if (params.value === null) { - return ; - } else if (typeof params.value === "object") { - const tooltip = JSON.stringify(params.value); - return ( - - - {JSON.stringify(params.value)} - - - ); - } else if ( - params.value !== "" && - !isNaN(params.value) && - !Number.isInteger(params.value) - ) { - const tooltip = params.value; - const display = parseFloat(params.value).toFixed(2); - return ( - - {display} - - ); - } - const tooltip = params.value; - return ( - - {params.value} - - ); - }, - }; - }), - ]; - - // Get the rows - const rows = []; - const indexes = Object.keys(data[columns[0]]); - indexes.forEach((index) => { - const row = { - id: index, - }; - columns.forEach((column) => { - row[column] = data[column][index]; - }); - rows.push(row); - }); - res.rows = rows; - } - - return res; -}; - /** * Results component to render the results of the exploration * @param {Object} props @@ -157,45 +29,15 @@ function Results({ id, updateFlag = false, setUpdateFlag = () => {} }) { const fetchExplorerResults = async () => { setLoading(true); getExplorerResults(id) - .then((results) => { - if (!results?.type) { - throw new Error("No result type specified in the response"); - } - - // Check if there is an appropriate visualizer - if (!Object.keys(visualizers).includes(results.type)) { - throw new Error(`No visualizer found for type: ${results.type}`); - } - setDataType(results.type); - - if (results.type === visualizersKeys.tabular) { - // Get the data from the orientation - const data = getDataFromOrientation( - results.data, - results.config.orient, - ); - setData({ - columns: data.columns.map((column) => { - return { - ...column, - // flex: 1, - }; - }), - rows: data.rows, - }); + .then((artifacts) => { + const [artifact] = artifacts ?? []; + if (!artifact?.type) { + throw new Error("No artifacts in the response"); } - if (results.type === visualizersKeys.plotly_json) { - setData(JSON.parse(results.data)); - } - - if (results.type === visualizersKeys.image_base64) { - setData(results.data); - } - - if (results.type === visualizersKeys.image_url) { - setData(results.data); - } + const visualizerData = artifactToVisualizerData(artifact); + setDataType(visualizerData.dataType); + setData(visualizerData.data); }) .catch((error) => { console.error(error); @@ -246,13 +88,11 @@ function Results({ id, updateFlag = false, setUpdateFlag = () => {} }) { )} - {!loading && dataType === visualizersKeys.image_base64 && ( - - )} - - {!loading && dataType === visualizersKeys.image_url && ( - - )} + {!loading && + (dataType === visualizersKeys.image_base64 || + dataType === visualizersKeys.image_url) && ( + + )} ); } diff --git a/DashAI/front/src/components/notebooks/explorer/useExplorerResults.jsx b/DashAI/front/src/components/notebooks/explorer/useExplorerResults.jsx index 6a7d9902d..39d047781 100644 --- a/DashAI/front/src/components/notebooks/explorer/useExplorerResults.jsx +++ b/DashAI/front/src/components/notebooks/explorer/useExplorerResults.jsx @@ -1,55 +1,9 @@ -import { Box, Typography, Tooltip } from "@mui/material"; import { useState, useEffect } from "react"; import { getExplorerResults } from "../../../api/explorer"; -import ImageVisualizer from ".//visualizations/ImageVisualizer"; -import PlotlyJsonVisualizer from "./visualizations/PlotlyJsonVisualizer"; -import TabularVisualizer from "./visualizations/TabularVisualizer"; -import { getExplorerStatus } from "../../../utils/explorerStatus"; -import { useTranslation } from "react-i18next"; - -/** - * NullCell component to render null values in the tabular visualizer - * @param {Object} props - */ -function NullCell({}) { - const [hover, setHover] = useState(false); - const { t } = useTranslation(["common"]); - return ( - setHover(true)} - onMouseLeave={() => setHover(false)} - > - - {hover ? t("common:none") : "-"} - - - ); -} - -const visualizers = { - tabular: TabularVisualizer, - plotly_json: PlotlyJsonVisualizer, - image_base64: ImageVisualizer, - image_url: ImageVisualizer, -}; - -const visualizersKeys = { - tabular: "tabular", - plotly_json: "plotly_json", - image_base64: "image_base64", - image_url: "image_url", -}; - -const ORIENTATIONS = { - dict: "dict", - records: "records", -}; +import { + artifactToVisualizerData, + visualizersKeys, +} from "../../../utils/artifactVisualizerData"; /** * Hook to manage explorer results data @@ -69,36 +23,15 @@ export function useExplorerResults(explorer) { setLoading(true); try { - const results = await getExplorerResults(explorer.id); - if (!results?.type) { - throw new Error("No result type specified in the response"); + const artifacts = await getExplorerResults(explorer.id); + const [artifact] = artifacts ?? []; + if (!artifact?.type) { + throw new Error("No artifacts in the response"); } - // Check if there is an appropriate visualizer - if (!Object.keys(visualizers).includes(results.type)) { - throw new Error(`No visualizer found for type: ${results.type}`); - } - - setDataType(results.type); - - // Process data based on type - if (results.type === visualizersKeys.tabular) { - const processedData = getDataFromOrientation( - results.data, - results.config.orient, - ); - setData({ - columns: processedData.columns, - rows: processedData.rows, - }); - } else if (results.type === visualizersKeys.plotly_json) { - setData(JSON.parse(results.data)); - } else if ( - results.type === visualizersKeys.image_base64 || - results.type === visualizersKeys.image_url - ) { - setData(results.data); - } + const visualizerData = artifactToVisualizerData(artifact); + setDataType(visualizerData.dataType); + setData(visualizerData.data); } catch (error) { console.error("Error fetching explorer results:", error); throw error; @@ -121,94 +54,4 @@ export function useExplorerResults(explorer) { }; } -/** - * Get the data from the orientation given. This function is used to transform the data - * from the explorer results to the format required by the tabular visualizer. - * @param {Object} data The data from the explorer results - * @param {String} orientation The orientation of the data - */ -const getDataFromOrientation = (data, orientation) => { - let res = { - columns: [], - rows: [], - }; - - if (orientation === ORIENTATIONS.records) { - throw new Error(`orientation ${orientation} not supported`); - } - - if (orientation === ORIENTATIONS.dict) { - // ‘dict’ (default) : dict like {column -> {index -> value}} - // Get the columns - const columns = Object.keys(data); - res.columns = [ - { - field: "id", - headerName: "Index", - renderCell: (params) => { - return ( - - {params.value} - - ); - }, - }, - ...columns.map((column) => { - return { - field: column, - headerName: column, - renderCell: (params) => { - if (params.value === null) { - return ; - } else if (typeof params.value === "object") { - const tooltip = JSON.stringify(params.value); - return ( - - - {JSON.stringify(params.value)} - - - ); - } else if ( - params.value !== "" && - !isNaN(params.value) && - !Number.isInteger(params.value) - ) { - const tooltip = params.value; - const display = parseFloat(params.value).toFixed(2); - return ( - - {display} - - ); - } - const tooltip = params.value; - return ( - - {params.value} - - ); - }, - }; - }), - ]; - - // Get the rows - const rows = []; - const indexes = Object.keys(data[columns[0]]); - indexes.forEach((index) => { - const row = { - id: index, - }; - columns.forEach((column) => { - row[column] = data[column][index]; - }); - rows.push(row); - }); - res.rows = rows; - } - - return res; -}; - export { visualizersKeys }; diff --git a/DashAI/front/src/components/pipelines/results/ResultsExploration.jsx b/DashAI/front/src/components/pipelines/results/ResultsExploration.jsx index 4a8b59fd6..381d10c17 100644 --- a/DashAI/front/src/components/pipelines/results/ResultsExploration.jsx +++ b/DashAI/front/src/components/pipelines/results/ResultsExploration.jsx @@ -4,120 +4,12 @@ import { PlotlyJsonVisualizer, ImageVisualizer, } from "../../explorations/Visualizations"; -import { Box, Typography, Tooltip } from "@mui/material"; +import { Box, Typography } from "@mui/material"; import { getExplorationResults } from "../../../api/pipeline"; - -function NullCell() { - const [hover, setHover] = useState(false); - return ( - setHover(true)} - onMouseLeave={() => setHover(false)} - > - - {hover ? "None" : "-"} - - - ); -} - -const visualizers = { - tabular: TabularVisualizer, - plotly_json: PlotlyJsonVisualizer, - image_base64: ImageVisualizer, - image_url: ImageVisualizer, -}; - -const visualizersKeys = { - tabular: "tabular", - plotly_json: "plotly_json", - image_base64: "image_base64", - image_url: "image_url", -}; - -const ORIENTATIONS = { - dict: "dict", - records: "records", -}; - -const getDataFromOrientation = (data, orientation) => { - let res = { - columns: [], - rows: [], - }; - - if (orientation === ORIENTATIONS.records) { - throw new Error(`orientation ${orientation} not supported`); - } - - if (orientation === ORIENTATIONS.dict) { - const columns = Object.keys(data); - res.columns = [ - { - field: "id", - headerName: "Index", - renderCell: (params) => ( - - {params.value} - - ), - }, - ...columns.map((column) => ({ - field: column, - headerName: column, - renderCell: (params) => { - if (params.value === null) return ; - if (typeof params.value === "object") { - const tooltip = JSON.stringify(params.value); - return ( - - - {JSON.stringify(params.value)} - - - ); - } - if ( - params.value !== "" && - !isNaN(params.value) && - !Number.isInteger(params.value) - ) { - const display = parseFloat(params.value).toFixed(2); - return ( - - {display} - - ); - } - return ( - - {params.value} - - ); - }, - })), - ]; - - const rows = []; - const indexes = Object.keys(data[columns[0]]); - indexes.forEach((index) => { - const row = { id: index }; - columns.forEach((column) => { - row[column] = data[column][index]; - }); - rows.push(row); - }); - res.rows = rows; - } - - return res; -}; +import { + artifactToVisualizerData, + visualizersKeys, +} from "../../../utils/artifactVisualizerData"; function Results({ pipelineId }) { const [explorationResults, setExplorationResults] = useState(null); @@ -137,36 +29,31 @@ function Results({ pipelineId }) { } }, [pipelineId]); - const renderVisualizer = (type, dataObj) => { - if (!Object.keys(visualizers).includes(type)) { - console.error(`No visualizer found for type: ${type}`); + const renderArtifact = (artifact, key) => { + let visualizerData; + try { + visualizerData = artifactToVisualizerData(artifact); + } catch (error) { + console.error(error); return null; } + const { dataType, data } = visualizerData; - if (type === visualizersKeys.tabular) { - const data = getDataFromOrientation(dataObj.data, dataObj.config.orient); + if (dataType === visualizersKeys.tabular) { return ( - + ); } - if (type === visualizersKeys.plotly_json) { - return ( - - ); - } - - if (type === visualizersKeys.image_base64) { - return ( - - ); + if (dataType === visualizersKeys.plotly_json) { + return ; } - if (type === visualizersKeys.image_url) { - return ; + if ( + dataType === visualizersKeys.image_base64 || + dataType === visualizersKeys.image_url + ) { + return ; } return null; @@ -193,7 +80,9 @@ function Results({ pipelineId }) { {i}: {result.exploration_type} {result.name ? ` | ${result.name}` : ""} - {renderVisualizer(result.results.type, result.results)} + {(result.results ?? []).map((artifact, artifactIndex) => + renderArtifact(artifact, `${explorationName}-${artifactIndex}`), + )} ), )} diff --git a/DashAI/front/src/components/shared/ArtifactRenderer.jsx b/DashAI/front/src/components/shared/ArtifactRenderer.jsx new file mode 100644 index 000000000..3448d91dd --- /dev/null +++ b/DashAI/front/src/components/shared/ArtifactRenderer.jsx @@ -0,0 +1,148 @@ +import React, { useMemo } from "react"; +import { + Box, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { useTheme, alpha } from "@mui/material/styles"; +import Plot from "react-plotly.js"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; + +import { applyThemeToLayout } from "../../utils/plotlyTheme"; + +/** + * Renders a single typed artifact ({type, payload, title}) returned by the + * backend (explainer plots, explorer results). Supported types: "plotly" + * (payload: plotly JSON string), "table" (payload: {columns, rows, + * highlight}), "image" (payload: {data, mime}) and "text" (payload: string). + * Unknown types fall back to preformatted text so nothing is silently lost. + */ +export default function ArtifactRenderer({ artifact }) { + const theme = useTheme(); + const { t } = useTranslation(["common"]); + + const parsedFigure = useMemo(() => { + if (artifact.type !== "plotly") return null; + try { + return typeof artifact.payload === "string" + ? JSON.parse(artifact.payload) + : artifact.payload; + } catch (error) { + console.error("Invalid plotly artifact payload", error); + return null; + } + }, [artifact]); + + const themedLayout = useMemo(() => { + if (!parsedFigure) return {}; + return applyThemeToLayout(parsedFigure.layout, theme); + }, [parsedFigure, theme]); + + const highlightedCells = useMemo(() => { + if (artifact.type !== "table") return new Set(); + const cells = artifact.payload?.highlight ?? []; + return new Set(cells.map((cell) => `${cell.row}-${cell.column}`)); + }, [artifact]); + + const renderContent = () => { + switch (artifact.type) { + case "plotly": + if (!parsedFigure) return null; + return ( + + ); + case "table": { + const { columns = [], rows = [] } = artifact.payload ?? {}; + return ( + + + + + {columns.map((column) => ( + {column} + ))} + + + + {rows.map((row, rowIndex) => ( + + {row.map((value, columnIndex) => ( + + {value === null ? "-" : String(value)} + + ))} + + ))} + +
+
+ ); + } + case "image": { + const { data = "", mime = "image/png" } = artifact.payload ?? {}; + return ( + + ); + } + case "text": + default: + return ( + + {typeof artifact.payload === "string" + ? artifact.payload + : JSON.stringify(artifact.payload, null, 2)} + + ); + } + }; + + return ( + + {artifact.title && ( + + {artifact.title} + + )} + {renderContent()} + + ); +} + +ArtifactRenderer.propTypes = { + artifact: PropTypes.shape({ + type: PropTypes.string.isRequired, + payload: PropTypes.any, + title: PropTypes.string, + }).isRequired, +}; diff --git a/DashAI/front/src/types/artifact.ts b/DashAI/front/src/types/artifact.ts new file mode 100644 index 000000000..aa1a1e86b --- /dev/null +++ b/DashAI/front/src/types/artifact.ts @@ -0,0 +1,13 @@ +/** + * Typed render artifact returned by explainer plot and explorer results + * endpoints. Payload shape depends on `type`: + * - "plotly": JSON string of a plotly figure. + * - "table": { columns: string[], rows: unknown[][], highlight: {row, column}[] }. + * - "text": plain string. + * - "image": { data: base64 string, mime: string }. + */ +export interface IArtifact { + type: string; + payload: unknown; + title: string | null; +} diff --git a/DashAI/front/src/types/explorer.ts b/DashAI/front/src/types/explorer.ts index 21ec81b3e..184c3eb6b 100644 --- a/DashAI/front/src/types/explorer.ts +++ b/DashAI/front/src/types/explorer.ts @@ -21,9 +21,3 @@ export enum ExplorerStatus { FINISHED, ERROR, } - -export interface IExplorerResults { - type: string; - data: object; - config: object; -} diff --git a/DashAI/front/src/utils/artifactVisualizerData.jsx b/DashAI/front/src/utils/artifactVisualizerData.jsx new file mode 100644 index 000000000..11c2d7918 --- /dev/null +++ b/DashAI/front/src/utils/artifactVisualizerData.jsx @@ -0,0 +1,124 @@ +import React, { useState } from "react"; +import PropTypes from "prop-types"; +import { Box, Tooltip, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +/** + * Keys of the visualizers used to render explorer results. + */ +export const visualizersKeys = { + tabular: "tabular", + plotly_json: "plotly_json", + image_base64: "image_base64", + image_url: "image_url", +}; + +/** + * NullCell component to render null values in the tabular visualizer + */ +function NullCell() { + const [hover, setHover] = useState(false); + const { t } = useTranslation(["common"]); + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + + {hover ? t("common:none") : "-"} + + + ); +} + +NullCell.propTypes = {}; + +const buildTableColumn = (field, headerName) => ({ + field, + headerName, + renderCell: (params) => { + if (params.value === null) { + return ; + } else if (typeof params.value === "object") { + const tooltip = JSON.stringify(params.value); + return ( + + + {JSON.stringify(params.value)} + + + ); + } else if ( + params.value !== "" && + !isNaN(params.value) && + !Number.isInteger(params.value) + ) { + const tooltip = params.value; + const display = parseFloat(params.value).toFixed(2); + return ( + + {display} + + ); + } + const tooltip = params.value; + return ( + + {params.value} + + ); + }, +}); + +/** + * Convert a typed artifact ({type, payload, title}) returned by the backend + * into the {dataType, data} pair consumed by the explorer visualizers + * (TabularVisualizer, PlotlyJsonVisualizer, ImageVisualizer). + * @param {Object} artifact The artifact to convert + * @returns {{dataType: string, data: any}} + */ +export function artifactToVisualizerData(artifact) { + switch (artifact?.type) { + case "plotly": { + const figure = + typeof artifact.payload === "string" + ? JSON.parse(artifact.payload) + : artifact.payload; + return { dataType: visualizersKeys.plotly_json, data: figure }; + } + case "table": { + const { columns = [], rows = [] } = artifact.payload ?? {}; + const gridColumns = columns.map((column) => + buildTableColumn(column, column === "index" ? "Index" : column), + ); + const gridRows = rows.map((row, rowIndex) => { + const gridRow = { id: rowIndex }; + columns.forEach((column, columnIndex) => { + gridRow[column] = row[columnIndex]; + }); + return gridRow; + }); + return { + dataType: visualizersKeys.tabular, + data: { columns: gridColumns, rows: gridRows }, + }; + } + case "image": { + const { data = "", mime = "image/png" } = artifact.payload ?? {}; + return { + dataType: visualizersKeys.image_url, + data: `data:${mime};base64,${data}`, + }; + } + default: + throw new Error( + `No visualizer found for artifact type: ${artifact?.type}`, + ); + } +} diff --git a/DashAI/front/src/utils/i18n/locales/de/explainers.json b/DashAI/front/src/utils/i18n/locales/de/explainers.json index cd792d738..36b1857e5 100644 --- a/DashAI/front/src/utils/i18n/locales/de/explainers.json +++ b/DashAI/front/src/utils/i18n/locales/de/explainers.json @@ -54,6 +54,7 @@ "selectDatasetToExplain": "Datensatz mit zu erklärenden Instanzen auswählen", "selectExplainer": "Name und Erklärungsmodell festlegen", "selectExplainerAndName": "Erklärungsmodell auswählen und Namen eingeben", + "selectInstance": "Instanz auswählen", "explainerInProgress": "Erklärungsmodell wird verarbeitet...", "splitSelectionSummary": "Prozentsatz: {{percentage}}% | Ausgewählte Zeilen: {{rowsSelected}} / {{totalRows}}", "searchExplainers": "Erklärungsmodelle suchen..." diff --git a/DashAI/front/src/utils/i18n/locales/en/explainers.json b/DashAI/front/src/utils/i18n/locales/en/explainers.json index d937ff713..cfc67f07d 100644 --- a/DashAI/front/src/utils/i18n/locales/en/explainers.json +++ b/DashAI/front/src/utils/i18n/locales/en/explainers.json @@ -54,6 +54,7 @@ "selectDatasetToExplain": "Select a dataset with instances to explain", "selectExplainer": "Set name and explainer", "selectExplainerAndName": "Select a explainer and enter a name", + "selectInstance": "Select an instance", "explainerInProgress": "Explainer in progress...", "splitSelectionSummary": "Percentage: {{percentage}}% | Rows selected: {{rowsSelected}} / {{totalRows}}", "searchExplainers": "Search explainers..." diff --git a/DashAI/front/src/utils/i18n/locales/es/explainers.json b/DashAI/front/src/utils/i18n/locales/es/explainers.json index 686a146a8..f093d5371 100644 --- a/DashAI/front/src/utils/i18n/locales/es/explainers.json +++ b/DashAI/front/src/utils/i18n/locales/es/explainers.json @@ -54,6 +54,7 @@ "selectDatasetToExplain": "Seleccione un dataset con instancias para explicar", "selectExplainer": "Establecer nombre y explicador", "selectExplainerAndName": "Seleccione un explicador e ingrese un nombre", + "selectInstance": "Selecciona una instancia", "explainerInProgress": "Explicador en progreso...", "splitSelectionSummary": "Porcentaje: {{percentage}}% | Filas seleccionadas: {{rowsSelected}} / {{totalRows}}", "searchExplainers": "Buscar explicadores..." diff --git a/DashAI/front/src/utils/i18n/locales/pt/explainers.json b/DashAI/front/src/utils/i18n/locales/pt/explainers.json index e2e0f82fa..d647b4602 100644 --- a/DashAI/front/src/utils/i18n/locales/pt/explainers.json +++ b/DashAI/front/src/utils/i18n/locales/pt/explainers.json @@ -54,6 +54,7 @@ "selectDatasetToExplain": "Selecione um conjunto de dados com instâncias para explicar", "selectExplainer": "Definir nome e explicador", "selectExplainerAndName": "Selecione um explicador e insira um nome", + "selectInstance": "Selecione uma instância", "explainerInProgress": "Explicador em andamento...", "splitSelectionSummary": "Percentual: {{percentage}}% | Linhas selecionadas: {{rowsSelected}} / {{totalRows}}", "searchExplainers": "Pesquisar explicadores..." diff --git a/DashAI/front/src/utils/i18n/locales/zh/explainers.json b/DashAI/front/src/utils/i18n/locales/zh/explainers.json index b5977b715..7ddd5dd0d 100644 --- a/DashAI/front/src/utils/i18n/locales/zh/explainers.json +++ b/DashAI/front/src/utils/i18n/locales/zh/explainers.json @@ -54,6 +54,7 @@ "selectDatasetToExplain": "选择包含待解释实例的数据集", "selectExplainer": "设置名称和解释器", "selectExplainerAndName": "选择解释器并输入名称", + "selectInstance": "选择一个实例", "explainerInProgress": "解释器运行中...", "splitSelectionSummary": "百分比:{{percentage}}% | 已选行数:{{rowsSelected}} / {{totalRows}}", "searchExplainers": "搜索解释器..." From 32c9a8a1713dd0a9e57685f0615f60be5d8bac7b Mon Sep 17 00:00:00 2001 From: Irozuku Date: Mon, 13 Jul 2026 14:39:28 -0400 Subject: [PATCH 7/8] fix: import visualizers keys from shared module --- .../src/components/notebooks/explorer/tabs/Results.jsx | 2 +- .../components/notebooks/explorer/useExplorerResults.jsx | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/DashAI/front/src/components/notebooks/explorer/tabs/Results.jsx b/DashAI/front/src/components/notebooks/explorer/tabs/Results.jsx index 18ffcf527..39c221244 100644 --- a/DashAI/front/src/components/notebooks/explorer/tabs/Results.jsx +++ b/DashAI/front/src/components/notebooks/explorer/tabs/Results.jsx @@ -1,6 +1,6 @@ import React from "react"; import { Box, CircularProgress } from "@mui/material"; -import { visualizersKeys } from "../useExplorerResults"; +import { visualizersKeys } from "../../../../utils/artifactVisualizerData"; import ImageVisualizer from "../visualizations/ImageVisualizer"; import PlotlyJsonVisualizer from "../visualizations/PlotlyJsonVisualizer"; import TabularVisualizer from "../visualizations/TabularVisualizer"; diff --git a/DashAI/front/src/components/notebooks/explorer/useExplorerResults.jsx b/DashAI/front/src/components/notebooks/explorer/useExplorerResults.jsx index 39d047781..8f6732cd6 100644 --- a/DashAI/front/src/components/notebooks/explorer/useExplorerResults.jsx +++ b/DashAI/front/src/components/notebooks/explorer/useExplorerResults.jsx @@ -1,9 +1,6 @@ import { useState, useEffect } from "react"; import { getExplorerResults } from "../../../api/explorer"; -import { - artifactToVisualizerData, - visualizersKeys, -} from "../../../utils/artifactVisualizerData"; +import { artifactToVisualizerData } from "../../../utils/artifactVisualizerData"; /** * Hook to manage explorer results data @@ -53,5 +50,3 @@ export function useExplorerResults(explorer) { fetchExplorerResults, }; } - -export { visualizersKeys }; From c2b5888267471663a1346c0ab30a67d165635245 Mon Sep 17 00:00:00 2001 From: Irozuku Date: Mon, 13 Jul 2026 16:59:04 -0400 Subject: [PATCH 8/8] fix: group explanation artifacts per instance in selector --- .../components/explainers/ExplainersPlot.jsx | 69 ++++++++++++++----- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/DashAI/front/src/components/explainers/ExplainersPlot.jsx b/DashAI/front/src/components/explainers/ExplainersPlot.jsx index 9a87e6048..0d1958bd7 100644 --- a/DashAI/front/src/components/explainers/ExplainersPlot.jsx +++ b/DashAI/front/src/components/explainers/ExplainersPlot.jsx @@ -6,6 +6,7 @@ import { Select, CircularProgress, Box, + Typography, } from "@mui/material"; import PropTypes from "prop-types"; import { useSnackbar } from "notistack"; @@ -27,10 +28,34 @@ function parseExplanationArtifacts(items) { ); } +/** + * Group consecutive artifacts sharing the same non null title. Explainers + * emit several artifacts per explained instance (e.g. a plot followed by a + * text summary) under one title; each group becomes a single entry in the + * instance selector and its artifacts render stacked together. Untitled + * artifacts stay in their own group. + */ +function groupArtifacts(artifacts) { + const groups = []; + artifacts.forEach((artifact) => { + const lastGroup = groups[groups.length - 1]; + if ( + artifact.title != null && + lastGroup && + lastGroup.title === artifact.title + ) { + lastGroup.artifacts.push(artifact); + } else { + groups.push({ title: artifact.title ?? null, artifacts: [artifact] }); + } + }); + return groups; +} + export default function ExplainersPlot({ explainer, scope }) { const { enqueueSnackbar } = useSnackbar(); - const [artifacts, setArtifacts] = useState([]); - const [currentArtifact, setCurrentArtifact] = useState(0); + const [groups, setGroups] = useState([]); + const [currentGroup, setCurrentGroup] = useState(0); const [loading, setLoading] = useState(true); const { t } = useTranslation(["explainers"]); @@ -39,19 +64,19 @@ export default function ExplainersPlot({ explainer, scope }) { try { const response = await getExplainerPlotRequest(explainer.id, scope); if (!response || response.length === 0) { - setArtifacts([]); - setCurrentArtifact(0); + setGroups([]); + setCurrentGroup(0); enqueueSnackbar(t("explainers:error.noData"), { variant: "warning", }); } else { - setArtifacts(parseExplanationArtifacts(response)); - // Reset currentArtifact when data updates to avoid stale index - setCurrentArtifact(0); + setGroups(groupArtifacts(parseExplanationArtifacts(response))); + // Reset currentGroup when data updates to avoid stale index + setCurrentGroup(0); } } catch (error) { - setArtifacts([]); - setCurrentArtifact(0); + setGroups([]); + setCurrentGroup(0); enqueueSnackbar(t("explainers:error.fetchExplainers"), { variant: "error", }); @@ -88,21 +113,21 @@ export default function ExplainersPlot({ explainer, scope }) { p: 1, }} > - {!loading && artifacts.length > 1 && ( + {!loading && groups.length > 1 && ( {t("explainers:label.selectInstance")}