Skip to content

Commit 71495e5

Browse files
authored
[MLflow Demo] Base implementation for demo framework (#19994)
Signed-off-by: Ben Wilson <benjamin.wilson@databricks.com>
1 parent b78c8b1 commit 71495e5

File tree

10 files changed

+599
-0
lines changed

10 files changed

+599
-0
lines changed

mlflow/demo/README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# MLflow Demo Data Framework
2+
3+
This module generates demo data for MLflow's GenAI features. For user-facing documentation, see the MLflow docs.
4+
5+
## User Entry Points
6+
7+
1. **CLI:** `mlflow demo` - Launches a temporary server with demo data
8+
2. **Home Page:** "Launch Demo" button - Adds demo data to an existing server
9+
3. **Settings:** "Clear Demo Data" - Removes all demo data
10+
11+
## Architecture
12+
13+
```
14+
mlflow/demo/
15+
├── base.py # BaseDemoGenerator, DemoFeature enum, DemoResult
16+
├── registry.py # DemoRegistry for managing generators
17+
└── generators/
18+
├── __init__.py # Registers generators (order matters!)
19+
├── prompts.py # Prompt versions and aliases
20+
├── traces.py # Sample traces with various patterns
21+
├── evaluation.py # Evaluation runs and datasets
22+
└── scorers.py # Registered LLM judges
23+
```
24+
25+
**Generator order matters** - some generators depend on others (e.g., traces depend on prompts, evaluation depends on traces).
26+
27+
## API Endpoints
28+
29+
| Endpoint | Method | Description |
30+
| ------------------------------------ | ------ | ------------------------------- |
31+
| `/ajax-api/3.0/mlflow/demo/generate` | POST | Generate demo data (idempotent) |
32+
| `/ajax-api/3.0/mlflow/demo/delete` | POST | Hard delete all demo data |
33+
34+
## Adding a New Generator
35+
36+
1. Add feature to `DemoFeature` enum in `base.py`
37+
2. Create generator class extending `BaseDemoGenerator`
38+
3. Register in `generators/__init__.py` (respect dependency order)
39+
40+
```python
41+
class MyFeatureDemoGenerator(BaseDemoGenerator):
42+
name = DemoFeature.MY_FEATURE
43+
version = 1
44+
45+
def generate(self) -> DemoResult:
46+
# Create demo data, return DemoResult with navigation_url
47+
...
48+
49+
def _data_exists(self) -> bool:
50+
# Return True if demo data exists
51+
...
52+
53+
def delete_demo(self) -> None:
54+
# Clean up demo data (optional, has default no-op)
55+
...
56+
```
57+
58+
## Naming Conventions
59+
60+
| Entity Type | Convention | Example |
61+
| ----------- | -------------------------------- | ------------------------- |
62+
| Experiment | `DEMO_EXPERIMENT_NAME` constant | `"MLflow Demo"` |
63+
| Prompts | `{DEMO_PROMPT_PREFIX}.<name>` | `"mlflow-demo.prompts.*"` |
64+
| Scorers | `{DEMO_PROMPT_PREFIX}.scorers.*` | `"mlflow-demo.scorers.*"` |
65+
| Metadata | `mlflow.demo.*` | `mlflow.demo.version` |
66+
67+
## Versioning
68+
69+
Each generator has a `version` attribute. When the version changes, old demo data is automatically deleted and regenerated. Bump versions when changes make old demo data incompatible with the current UI.
70+
71+
## Testing
72+
73+
```bash
74+
uv run pytest tests/demo/ -v
75+
```

mlflow/demo/__init__.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import logging
2+
3+
from mlflow.demo.base import DEMO_EXPERIMENT_NAME, DEMO_PROMPT_PREFIX, BaseDemoGenerator, DemoResult
4+
from mlflow.demo.registry import demo_registry
5+
6+
_logger = logging.getLogger(__name__)
7+
8+
__all__ = [
9+
"DEMO_EXPERIMENT_NAME",
10+
"DEMO_PROMPT_PREFIX",
11+
"BaseDemoGenerator",
12+
"DemoResult",
13+
"demo_registry",
14+
"generate_all_demos",
15+
]
16+
17+
18+
def generate_all_demos() -> list[DemoResult]:
19+
results = []
20+
for name in demo_registry.list_generators():
21+
generator_cls = demo_registry.get(name)
22+
generator = generator_cls()
23+
if generator.is_generated():
24+
_logger.debug(f"Demo '{name}' already exists, skipping")
25+
continue
26+
_logger.info(f"Generating demo data for '{name}'")
27+
result = generator.generate()
28+
generator.store_version()
29+
results.append(result)
30+
return results

mlflow/demo/base.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from abc import ABC, abstractmethod
2+
from dataclasses import dataclass
3+
from enum import Enum
4+
5+
DEMO_EXPERIMENT_NAME = "MLflow Demo"
6+
DEMO_PROMPT_PREFIX = "mlflow-demo"
7+
8+
9+
class DemoFeature(str, Enum):
10+
"""Enumeration of demo features that can be generated."""
11+
12+
TRACES = "traces"
13+
EVALUATION = "evaluation"
14+
15+
16+
@dataclass
17+
class DemoResult:
18+
"""Result returned by a demo generator after creating demo data.
19+
20+
Attributes:
21+
feature: The demo feature that was generated. Use DemoFeature enum values.
22+
entity_ids: List of identifiers for created entities (e.g., trace IDs, dataset names).
23+
navigation_url: URL path to navigate to view the demo data in the UI.
24+
"""
25+
26+
feature: DemoFeature
27+
entity_ids: list[str]
28+
navigation_url: str
29+
30+
31+
class BaseDemoGenerator(ABC):
32+
"""Abstract base class for demo data generators.
33+
34+
Subclasses must define a `name` class attribute and implement the `generate()`
35+
and `_data_exists()` methods. Generators are registered with the `demo_registry`
36+
and invoked during server startup to populate demo data.
37+
38+
Versioning:
39+
Each generator has a `version` class attribute (default: 1). When demo data
40+
is generated, the version is stored as a tag on the MLflow Demo experiment.
41+
On subsequent startups, if the stored version doesn't match the generator's
42+
current version, stale data is cleaned up and regenerated.
43+
44+
Bump the version when making breaking changes to demo data format.
45+
46+
Example:
47+
class MyDemoGenerator(BaseDemoGenerator):
48+
name = DemoFeature.TRACES
49+
version = 1 # Bump when demo format changes
50+
51+
def generate(self) -> DemoResult:
52+
# Create demo data using MLflow APIs
53+
return DemoResult(...)
54+
55+
def _data_exists(self) -> bool:
56+
# Check if demo data exists (version handled by base class)
57+
return True/False
58+
59+
def delete_demo(self) -> None:
60+
# Optional: delete demo data (called on version mismatch or via UI)
61+
pass
62+
"""
63+
64+
name: DemoFeature | None = None
65+
version: int = 1
66+
67+
def __init__(self):
68+
if self.name is None:
69+
raise ValueError(f"{self.__class__.__name__} must define 'name' class attribute")
70+
71+
@abstractmethod
72+
def generate(self) -> DemoResult:
73+
"""Generate demo data for this feature. Returns a DemoResult with details."""
74+
75+
@abstractmethod
76+
def _data_exists(self) -> bool:
77+
"""Check if demo data exists (regardless of version)."""
78+
79+
def delete_demo(self) -> None:
80+
"""Delete demo data created by this generator.
81+
82+
Called automatically when version mismatches on startup, or can be called
83+
directly via API for user-initiated deletion. Override to implement cleanup.
84+
"""
85+
86+
def is_generated(self) -> bool:
87+
"""Check if demo data exists with a matching version.
88+
89+
Returns True only if data exists AND the stored version matches the current
90+
generator version. If version mismatches, calls delete_demo() and
91+
returns False to trigger regeneration.
92+
"""
93+
if not self._data_exists():
94+
return False
95+
96+
stored_version = self._get_stored_version()
97+
if stored_version is None or stored_version != self.version:
98+
self.delete_demo()
99+
return False
100+
101+
return True
102+
103+
def _get_stored_version(self) -> int | None:
104+
"""Get the stored version for this generator from experiment tags."""
105+
from mlflow.tracking._tracking_service.utils import _get_store
106+
107+
store = _get_store()
108+
try:
109+
experiment = store.get_experiment_by_name(DEMO_EXPERIMENT_NAME)
110+
if experiment is None:
111+
return None
112+
version_tag = experiment.tags.get(f"mlflow.demo.version.{self.name}")
113+
return int(version_tag) if version_tag else None
114+
except Exception:
115+
return None
116+
117+
def store_version(self) -> None:
118+
"""Store the current version in experiment tags. Called after successful generation."""
119+
from mlflow.tracking._tracking_service.utils import _get_store
120+
121+
store = _get_store()
122+
if experiment := store.get_experiment_by_name(DEMO_EXPERIMENT_NAME):
123+
store.set_experiment_tag(
124+
experiment.experiment_id,
125+
f"mlflow.demo.version.{self.name}",
126+
str(self.version),
127+
)

mlflow/demo/generators/__init__.py

Whitespace-only changes.

mlflow/demo/registry.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from mlflow.demo.base import DemoFeature
6+
7+
if TYPE_CHECKING:
8+
from mlflow.demo.base import BaseDemoGenerator
9+
10+
11+
class DemoRegistry:
12+
"""Registry for demo data generators.
13+
14+
Provides registration and lookup of BaseDemoGenerator subclasses by name.
15+
The global `demo_registry` instance is used by `generate_all_demos()` to
16+
discover and run all registered generators.
17+
"""
18+
19+
def __init__(self):
20+
self._generators: dict[DemoFeature, type[BaseDemoGenerator]] = {}
21+
22+
def register(self, generator_cls: type[BaseDemoGenerator]) -> None:
23+
name = generator_cls.name
24+
if not name:
25+
raise ValueError(f"{generator_cls.__name__} must define 'name' class attribute")
26+
if name in self._generators:
27+
raise ValueError(f"Generator '{name}' is already registered")
28+
self._generators[name] = generator_cls
29+
30+
def get(self, name: DemoFeature) -> type[BaseDemoGenerator]:
31+
if name not in self._generators:
32+
available = list(self._generators.keys())
33+
raise ValueError(f"Generator '{name}' not found. Available: {available}")
34+
return self._generators[name]
35+
36+
def list_generators(self) -> list[DemoFeature]:
37+
return list(self._generators.keys())
38+
39+
def __contains__(self, name: DemoFeature) -> bool:
40+
return name in self._generators
41+
42+
43+
demo_registry = DemoRegistry()

tests/demo/__init__.py

Whitespace-only changes.

tests/demo/conftest.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import pytest
2+
3+
from mlflow.demo.base import BaseDemoGenerator, DemoFeature, DemoResult
4+
from mlflow.demo.registry import DemoRegistry
5+
6+
7+
class StubGenerator(BaseDemoGenerator):
8+
name = DemoFeature.TRACES
9+
10+
def __init__(self, version: int = 1):
11+
self._version = version
12+
self.generate_called = False
13+
self.data_exists_value = False
14+
self.stored_version_value = None
15+
self.delete_demo_called = False
16+
super().__init__()
17+
18+
@property
19+
def version(self) -> int:
20+
return self._version
21+
22+
@version.setter
23+
def version(self, value: int) -> None:
24+
self._version = value
25+
26+
def generate(self) -> DemoResult:
27+
self.generate_called = True
28+
return DemoResult(
29+
feature=self.name,
30+
entity_ids=["entity-1"],
31+
navigation_url="/stub",
32+
)
33+
34+
def _data_exists(self) -> bool:
35+
return self.data_exists_value
36+
37+
def _get_stored_version(self) -> int | None:
38+
return self.stored_version_value
39+
40+
def store_version(self) -> None:
41+
self.stored_version_value = self.version
42+
43+
def delete_demo(self) -> None:
44+
self.delete_demo_called = True
45+
46+
47+
class AnotherStubGenerator(BaseDemoGenerator):
48+
name = DemoFeature.EVALUATION
49+
50+
def generate(self) -> DemoResult:
51+
return DemoResult(
52+
feature=self.name,
53+
entity_ids=["entity-2"],
54+
navigation_url="/evaluation",
55+
)
56+
57+
def _data_exists(self) -> bool:
58+
return False
59+
60+
61+
@pytest.fixture
62+
def stub_generator():
63+
return StubGenerator()
64+
65+
66+
@pytest.fixture
67+
def another_stub_generator():
68+
return AnotherStubGenerator()
69+
70+
71+
@pytest.fixture
72+
def fresh_registry():
73+
return DemoRegistry()

0 commit comments

Comments
 (0)