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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Add plot_overrides to explorer

Revision ID: f7a4c2e91b60
Revises: d5b3c8f2a041
Create Date: 2026-08-11 00:00:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa

from alembic import op

revision: str = "f7a4c2e91b60"
down_revision: Union[str, None] = "d5b3c8f2a041"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column("explorer", sa.Column("plot_overrides", sa.JSON(), nullable=True))


def downgrade() -> None:
op.drop_column("explorer", "plot_overrides")
73 changes: 3 additions & 70 deletions DashAI/back/api/api_v1/endpoints/explainers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from fastapi import APIRouter, Depends, status
from fastapi.exceptions import HTTPException
from kink import di, inject
from pydantic import BaseModel
from sqlalchemy import exc, select

from DashAI.back.api.api_v1.schemas.explainers_params import (
Expand All @@ -13,6 +12,7 @@
ValidateDatasetParams,
ValidDatasetsParams,
)
from DashAI.back.core.artifacts import PlotOverrideBody, apply_plot_overrides
from DashAI.back.core.enums.status import ExplainerStatus
from DashAI.back.dependencies.database.models import (
Dataset,
Expand All @@ -31,73 +31,6 @@
router = APIRouter()


def _apply_overrides(artifacts: list, overrides: dict | None) -> list:
"""Replace plotly artifact payloads with stored edited figures.

Leaves nested inside a ``"grouped"`` selector (see
:class:`DashAI.back.core.artifacts.GroupedArtifacts`), i.e. under each
group's ``artifacts``, are matched by their stamped ``"index"`` just
like top level ones, so a group's plotly artifact can be edited/reset the
same way as a top level one.

Parameters
----------
artifacts : list
Normalized artifact/grouped dicts from ``normalize_artifacts``.
overrides : dict or None
Mapping of ``str(index)`` to an edited plotly figure (JSON string).

Returns
-------
list
The artifacts with overridden plotly payloads applied.
"""
if not overrides:
return artifacts
import json

leaves_by_index = {}

def collect_leaves(items):
for item in items:
if item.get("type") == "grouped":
for group in item.get("groups", []):
collect_leaves(group.get("artifacts", []))
else:
leaves_by_index[item.get("index")] = item

collect_leaves(artifacts)

for key, figure in overrides.items():
try:
idx = int(key)
except (TypeError, ValueError):
continue
leaf = leaves_by_index.get(idx)
if leaf is not None and leaf.get("type") == "plotly":
leaf["payload"] = figure if isinstance(figure, str) else json.dumps(figure)
# Flag so the frontend renders the user's edited figure verbatim
# instead of re-applying the app theme (which would clobber the
# edited colors/background).
leaf["overridden"] = True
return artifacts


class PlotOverrideBody(BaseModel):
"""Request body for saving one plot override.

Parameters
----------
index : int
Artifact index whose payload is being overridden.
figure : object
The edited plotly figure, either a JSON string or a dict.
"""

index: int
figure: object


@router.get("/global")
@inject
async def get_global_explainers(
Expand Down Expand Up @@ -271,7 +204,7 @@ async def get_global_explanation_plot(
detail="Internal database error",
) from e

return _apply_overrides(normalize_artifacts(plot), plot_overrides)
return apply_plot_overrides(normalize_artifacts(plot), plot_overrides)


@router.post("/global", status_code=status.HTTP_201_CREATED)
Expand Down Expand Up @@ -555,7 +488,7 @@ async def get_local_explanation_plot(
detail="Internal database error",
) from e

return _apply_overrides(
return apply_plot_overrides(
normalize_artifacts(plots, create_grouped=True), plot_overrides
)

Expand Down
104 changes: 78 additions & 26 deletions DashAI/back/api/api_v1/endpoints/explorers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ExplorerCreate,
ExplorerResultsOptions,
)
from DashAI.back.core.artifacts import PlotOverrideBody, apply_plot_overrides
from DashAI.back.core.enums.status import ExplorerStatus
from DashAI.back.dependencies.database.models import Dataset, Explorer, Notebook

Expand Down Expand Up @@ -368,57 +369,108 @@ async def get_explorer_results(
component_registry=component_registry,
session=db,
)
# Stored user edits win over the computed figure, and are flagged so
# the frontend renders them verbatim instead of re-theming them.
artifacts = apply_plot_overrides(artifacts, explorer_info.plot_overrides)

return artifacts


@router.put("/{explorer_id}/results/")
@inject
async def update_explorer_results(
params: dict,
explorer_id: int,
body: PlotOverrideBody,
session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]),
component_registry: "ComponentRegistry" = Depends(lambda: di["component_registry"]),
):
"""Store the plot edits (data and layout) made from the frontend.
"""Persist an edited plotly figure for one artifact of an exploration.

The edit is stored on the explorer record keyed by artifact index. The
stored artifacts file is left untouched, so the computed figure survives
and ``delete_explorer_results_override`` can restore it.

The edited figure replaces the payload of the first stored artifact; the
raw exploration file is left untouched as the original result.
Parameters
----------
explorer_id : int
Id of the explorer whose plot is being edited.
body : PlotOverrideBody
The artifact index and the edited plotly figure.
session_factory : Callable[..., ContextManager[Session]]
Factory yielding a SQLAlchemy session.

Returns
-------
dict
A confirmation message.

Raises
------
HTTPException
404 if the explorer does not exist.
"""
import json

from DashAI.back.exploration.artifact_store import write_artifacts

db: "Session"
with session_factory() as db:
explorer = db.query(Explorer).get(explorer_id)
explorer = db.get(Explorer, explorer_id)
if explorer is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Explorer not found",
)

artifacts = load_explorer_artifacts(
explorer=explorer,
component_registry=component_registry,
session=db,
overrides = dict(explorer.plot_overrides or {})
figure = body.figure
overrides[str(body.index)] = (
figure if isinstance(figure, str) else json.dumps(figure)
)
explorer.plot_overrides = overrides
db.commit()

return {"message": "Explorer results updated successfully"}


@router.delete("/{explorer_id}/results/override/{index}")
@inject
async def delete_explorer_results_override(
explorer_id: int,
index: int,
session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]),
):
"""Remove a stored plot override, reverting to the computed figure.

Parameters
----------
explorer_id : int
Id of the explorer.
index : int
Artifact index whose override is removed. Removing an index that has
no override is a no-op.
session_factory : Callable[..., ContextManager[Session]]
Factory yielding a SQLAlchemy session.

Returns
-------
dict
A confirmation message.

if not artifacts:
Raises
------
HTTPException
404 if the explorer does not exist.
"""
db: "Session"
with session_factory() as db:
explorer = db.get(Explorer, explorer_id)
if explorer is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Explorer has no results to update",
status_code=status.HTTP_404_NOT_FOUND,
detail="Explorer not found",
)

# update results
try:
artifacts[0] = {**artifacts[0], "payload": json.dumps(params)}
write_artifacts(explorer.artifacts_path, artifacts)
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Error while updating explorer results",
) from e
overrides = dict(explorer.plot_overrides or {})
overrides.pop(str(index), None)
explorer.plot_overrides = overrides or None
db.commit()

return {"message": "Explorer results updated successfully"}
return {"message": "Explorer results restored successfully"}
70 changes: 70 additions & 0 deletions DashAI/back/core/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"""

import base64
import json
from typing import Annotated, Any, Dict, List, Literal, Optional, Union

from pydantic import (
Expand Down Expand Up @@ -554,3 +555,72 @@ def build_image_input_artifact(
artifact = ImageArtifact.from_dashai_image(image, title=title)
artifact.role = "input"
return artifact


class PlotOverrideBody(BaseModel):
"""Request body for saving one plot override.

Parameters
----------
index : int
Artifact index whose payload is being overridden.
figure : object
The edited plotly figure, either a JSON string or a dict.
"""

index: int
figure: object


def apply_plot_overrides(
artifacts: List[Dict[str, Any]], overrides: Optional[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Replace plotly artifact payloads with stored edited figures.

Leaves nested inside a ``"grouped"`` selector (see
:class:`GroupedArtifacts`), i.e. under each group's ``artifacts``, are
matched by their stamped ``"index"`` just like top level ones, so a
group's plotly artifact can be edited/reset the same way as a top level
one.

Parameters
----------
artifacts : List[Dict[str, Any]]
Normalized artifact/grouped dicts from :func:`normalize_artifacts`.
overrides : Optional[Dict[str, Any]]
Mapping of ``str(index)`` to an edited plotly figure (JSON string or
dict).

Returns
-------
List[Dict[str, Any]]
The artifacts with overridden plotly payloads applied.
"""
if not overrides:
return artifacts

leaves_by_index: Dict[Any, Dict[str, Any]] = {}

def collect_leaves(items: List[Dict[str, Any]]) -> None:
for item in items:
if item.get("type") == "grouped":
for group in item.get("groups", []):
collect_leaves(group.get("artifacts", []))
else:
leaves_by_index[item.get("index")] = item

collect_leaves(artifacts)

for key, figure in overrides.items():
try:
idx = int(key)
except (TypeError, ValueError):
continue
leaf = leaves_by_index.get(idx)
if leaf is not None and leaf.get("type") == "plotly":
leaf["payload"] = figure if isinstance(figure, str) else json.dumps(figure)
# Flag so the frontend renders the user's edited figure verbatim
# instead of re-applying the app theme (which would clobber the
# edited colors/background).
leaf["overridden"] = True
return artifacts
4 changes: 4 additions & 0 deletions DashAI/back/dependencies/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,10 @@ class Explorer(Base):
# Render artifacts built once, when the exploration is created, so results
# keep rendering after the explorer class is removed from the registry.
artifacts_path: Mapped[str] = mapped_column(String, nullable=True)
# Per artifact index -> edited plotly figure, applied over the stored
# artifacts on read. Kept apart from artifacts_path so the computed figure
# survives an edit and a reset can restore it.
plot_overrides: Mapped[JSON] = mapped_column(JSON, nullable=True)
# Metadata
name: Mapped[str] = mapped_column(String, nullable=True)

Expand Down
1 change: 1 addition & 0 deletions DashAI/front/src/api/__mocks__/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export default {
post: jest.fn(),
get: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
};
Loading
Loading