Files
2026-07-13 13:22:34 +08:00

518 lines
17 KiB
Python

"""MLflow CLI commands for Assistant integration."""
import sys
import threading
import time
from pathlib import Path
import click
from mlflow.assistant.config import AssistantConfig, ProjectConfig, SkillsConfig
from mlflow.assistant.providers import AssistantProvider, list_providers
from mlflow.assistant.providers.base import ProviderNotConfiguredError
from mlflow.assistant.skill_installer import install_skills
class Spinner:
"""Simple spinner animation for long-running operations."""
def __init__(self, message: str = "Loading"):
self.message = message
self.spinning = False
self.thread = None
self.frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
def _spin(self):
i = 0
while self.spinning:
frame = self.frames[i % len(self.frames)]
sys.stdout.write(f"\r{frame} {self.message}")
sys.stdout.flush()
time.sleep(0.1)
i += 1
def __enter__(self):
self.spinning = True
self.thread = threading.Thread(target=self._spin, name="Spinner")
self.thread.start()
return self
def __exit__(self, *args):
self.spinning = False
if self.thread:
self.thread.join()
sys.stdout.write("\r" + " " * (len(self.message) + 4) + "\r")
sys.stdout.flush()
@click.command("assistant")
@click.option(
"--configure",
is_flag=True,
help="Configure or reconfigure the assistant settings",
)
def commands(configure: bool):
"""MLflow Assistant - AI-powered trace analysis.
Run 'mlflow assistant --configure' to set up the assistant.
"""
if configure:
_run_configuration()
else:
# Check if already configured
config = AssistantConfig.load()
if not config.providers:
click.secho(
"Assistant is not configured. Please run: mlflow assistant --configure",
fg="yellow",
)
else:
click.secho(
"Assistant launch is not yet implemented. To use Assistant, run `mlflow assistant "
"--configure` to setup, then launch the MLflow UI manually.",
fg="yellow",
)
def _run_configuration():
"""Configure MLflow Assistant for the UI.
This interactive command sets up the AI assistant feature that allows you
to analyze MLflow traces directly from the UI.
The command will:
1. Ask which provider to use (Claude Code for now)
2. Check provider availability
3. Optionally connect an experiment with code repository
4. Ask which model to use
5. Ask where to install skills (user-level or project-level)
6. Install provider-specific skills
7. Save configuration
Example:
mlflow assistant --configure
"""
click.echo()
click.secho("╔══════════════════════════════════════════╗", fg="cyan")
click.secho("║ * . * . * ║", fg="cyan")
click.secho("║ . * MLflow Assistant Setup * . ║", fg="cyan", bold=True)
click.secho("║ * . * . * ║", fg="cyan")
click.secho("╚══════════════════════════════════════════╝", fg="cyan")
click.echo()
# Step 1: Select provider
provider = _prompt_provider()
if provider is None:
return
# Step 2: Check provider availability
if not _check_provider(provider):
return
# Step 3: Optionally connect experiment with code repository
project_path = _prompt_experiment_path()
# Step 4: Ask for model
model = _prompt_model()
# Step 5: Ask for skill location
skills_config = _prompt_skill_location(project_path)
# Step 6: Install skills
skill_path = _install_skills(provider, skills_config, project_path)
# Step 7: Save configuration
_save_config(provider, model, skills_config)
# Show success message
_show_init_success(provider, model, skill_path)
def _prompt_provider() -> AssistantProvider | None:
"""Prompt user to select a provider."""
providers = list_providers()
click.secho("Step 1/4: Select AI Provider", fg="cyan", bold=True)
click.secho("-" * 30, fg="cyan")
click.echo()
for i, provider in enumerate(providers, 1):
marker = click.style(" [recommended]", fg="green") if i == 1 else ""
click.echo(f" {i}. {provider.display_name}{marker}")
click.secho(f" {provider.description}", dim=True)
click.echo()
click.secho(" More providers coming soon...", dim=True)
click.echo()
default_provider = providers[0]
choice = click.prompt(
click.style(f"Select provider [1: {default_provider.display_name}]", fg="bright_blue"),
default="1",
type=click.Choice([str(i) for i in range(1, len(providers) + 1)]),
show_choices=False,
show_default=False,
)
provider = providers[int(choice) - 1]
click.echo()
return provider
def _check_provider(provider: AssistantProvider) -> bool:
click.secho("Step 2/4: Checking Provider", fg="cyan", bold=True)
click.secho("-" * 30, fg="cyan")
click.echo()
if not provider.is_available():
click.secho(
f"{provider.display_name} is not available. "
"Please ensure it is installed and accessible in your PATH.",
fg="red",
)
click.echo()
return False
try:
spinner_msg = "Checking connection... " + click.style(
"(this may take a few seconds)", dim=True
)
with Spinner(spinner_msg):
provider.check_connection()
click.secho("Connection verified", fg="green")
click.echo()
return True
except ProviderNotConfiguredError as e:
click.secho(str(e), fg="red")
click.echo()
return False
def _fetch_recent_experiments(tracking_uri: str, max_results: int = 5) -> list[tuple[str, str]]:
"""Fetch recent experiments from the tracking server.
Returns:
List of (experiment_id, experiment_name) tuples.
"""
import mlflow
original_uri = mlflow.get_tracking_uri()
try:
mlflow.set_tracking_uri(tracking_uri)
client = mlflow.MlflowClient()
experiments = client.search_experiments(
max_results=max_results,
order_by=["last_update_time DESC"],
)
return [(exp.experiment_id, exp.name) for exp in experiments]
except Exception:
return []
finally:
mlflow.set_tracking_uri(original_uri)
def _resolve_experiment_id(tracking_uri: str, name_or_id: str) -> str | None:
"""Resolve experiment name or ID to experiment ID.
Args:
tracking_uri: MLflow tracking server URI.
name_or_id: Experiment name or ID.
Returns:
Experiment ID if found, None otherwise.
"""
import mlflow
original_uri = mlflow.get_tracking_uri()
try:
mlflow.set_tracking_uri(tracking_uri)
client = mlflow.MlflowClient()
# First try to get by ID (if it looks like an ID)
if name_or_id.isdigit():
try:
if exp := client.get_experiment(name_or_id):
return exp.experiment_id
except Exception:
pass
# Try to get by name
if exp := client.get_experiment_by_name(name_or_id):
return exp.experiment_id
return None
except Exception:
return None
finally:
mlflow.set_tracking_uri(original_uri)
def _prompt_experiment_path() -> Path | None:
"""Prompt user to optionally connect an experiment with code repository.
Returns:
The project path if configured, None otherwise.
"""
click.secho("Step 3/5: Experiment & Code Context ", fg="cyan", bold=True, nl=False)
click.secho("[Optional, Recommended]", fg="green", bold=True)
click.secho("-" * 30, fg="cyan")
click.echo()
click.echo("You can connect an experiment with a code repository to give")
click.echo("the assistant context about your source code for better analysis.")
click.secho("(You can also set this up later in the MLflow UI.)", dim=True)
click.echo()
connect = click.confirm(
click.style(
"Do you want to connect an experiment with a code repository?", fg="bright_blue"
),
default=True,
)
if not connect:
click.echo()
return None
click.echo()
# Ask for tracking URI to fetch experiments
tracking_uri = click.prompt(
click.style("Enter the MLflow tracking server URI", fg="bright_blue"),
default="http://localhost:5000",
)
click.echo()
click.secho("Fetching recent experiments...", dim=True)
# Fetch recent experiments
experiments = _fetch_recent_experiments(tracking_uri)
if not experiments:
click.secho("Could not fetch experiments from the server.", fg="yellow")
click.echo("You can set this up later in the MLflow UI.")
click.echo()
return None
click.echo()
click.echo(click.style("Select an experiment to connect:", fg="bright_blue"))
click.echo()
for i, (exp_id, exp_name) in enumerate(experiments, 1):
click.echo(f" {i}. {exp_name} (ID: {exp_id})")
other_option = len(experiments) + 1
click.echo(f" {other_option}. Enter experiment name or ID manually")
click.echo()
choice = click.prompt(
click.style("Select experiment", fg="bright_blue"),
type=click.IntRange(1, other_option),
default=1,
)
if choice == other_option:
while True:
click.echo()
name_or_id = click.prompt(
click.style("Experiment name or ID", fg="bright_blue"), default=""
)
if not name_or_id:
click.secho("No experiment specified. Please try again.", fg="yellow")
continue
experiment_id = _resolve_experiment_id(tracking_uri, name_or_id)
if experiment_id:
# Use the input as display name (could be name or ID)
experiment_name = name_or_id
break
click.secho(
f"Experiment '{name_or_id}' not found. Please try again.",
fg="red",
)
else:
experiment_id, experiment_name = experiments[choice - 1]
click.secho(
f"Experiment '{experiment_name}' selected",
fg="green",
)
click.echo()
# Ask for project path
default_path = str(Path.cwd())
while True:
raw_path = click.prompt(
click.style("Enter the path to your project directory:", fg="bright_blue"),
default=default_path,
)
# Expand ~ and resolve relative paths
expanded_path = Path(raw_path).expanduser().resolve()
if expanded_path.is_dir():
project_path = str(expanded_path)
break
click.secho(f"Directory '{raw_path}' does not exist. Please try again.", fg="red")
# Save the project path mapping locally
try:
config = AssistantConfig.load()
config.projects[experiment_id] = ProjectConfig(type="local", location=project_path)
config.save()
click.secho(
f"Project path {project_path} is saved for experiment '{experiment_name}'",
fg="green",
)
except Exception as e:
click.secho(f"Error saving project path: {e}", fg="red")
click.echo()
return expanded_path
def _prompt_model() -> str:
"""Prompt user for model selection."""
click.secho("Step 4/5: Model Selection", fg="cyan", bold=True)
click.secho("-" * 30, fg="cyan")
click.echo()
click.echo("Choose a model for analysis:")
click.secho(" - Press Enter to use the default model (recommended)", dim=True)
click.secho(" - Or type a specific model name (e.g., claude-sonnet-4-20250514)", dim=True)
click.echo()
model = click.prompt(click.style("Model", fg="bright_blue"), default="default")
click.echo()
return model
def _prompt_skill_location(project_path: Path | None) -> SkillsConfig:
"""Prompt user for skill installation location.
Args:
project_path: The project path from experiment setup, or None if skipped.
Returns:
SkillsConfig with the selected location type and optional custom path.
"""
click.secho("Step 5/5: Skill Installation Location", fg="cyan", bold=True)
click.secho("-" * 30, fg="cyan")
click.echo()
click.echo("Choose where to install MLflow skills for Assistant:")
click.echo()
# TODO: Update this when we support other providers
user_path = Path.home() / ".claude" / "skills"
click.echo(f" 1. User level ({user_path})")
click.secho(" Skills available globally across all projects", dim=True)
click.echo()
if project_path:
project_skill_path = project_path / ".claude" / "skills"
click.echo(f" 2. Project level ({project_skill_path})")
click.secho(" Skills available only in this project", dim=True)
click.echo()
click.echo(" 3. Custom location")
click.secho(" Specify a custom path for skills", dim=True)
click.echo()
valid_choices = ["1", "2", "3"]
else:
click.echo(" 2. Custom location")
click.secho(" Specify a custom path for skills", dim=True)
click.echo()
valid_choices = ["1", "2"]
choice = click.prompt(
click.style("Select location [1: User level]", fg="bright_blue"),
default="1",
type=click.Choice(valid_choices),
show_choices=False,
show_default=False,
)
click.echo()
if choice == "1":
return SkillsConfig(type="global")
elif choice == "2" and project_path:
return SkillsConfig(type="project")
else:
# Custom location
while True:
raw_path = click.prompt(
click.style("Enter the custom path for skills", fg="bright_blue"),
default=str(user_path),
)
expanded_path = Path(raw_path).expanduser().resolve()
# For custom paths, we'll create the directory, so just check parent exists
if expanded_path.parent.exists() or expanded_path.exists():
click.echo()
return SkillsConfig(type="custom", custom_path=str(expanded_path))
click.secho(
f"Parent directory '{expanded_path.parent}' does not exist. Please try again.",
fg="red",
)
def _install_skills(
provider: AssistantProvider, skills_config: SkillsConfig, project_path: Path | None
) -> Path:
"""Install skills bundled with MLflow.
Returns:
The resolved path where skills were installed.
"""
match skills_config.type:
case "global":
skill_path = provider.resolve_skills_path(Path.home())
case "project":
if project_path is None:
raise ValueError("project_path is required for 'project' skills location")
skill_path = provider.resolve_skills_path(project_path)
case "custom":
if skills_config.custom_path is None:
raise ValueError("custom_path is required for 'custom' skills location")
skill_path = Path(skills_config.custom_path).expanduser()
if installed_skills := install_skills(skill_path):
for skill in installed_skills:
click.secho(f" - {skill}")
else:
click.secho("No skills available to install.", fg="yellow")
click.echo()
return skill_path
def _save_config(provider: AssistantProvider, model: str, skills_config: SkillsConfig) -> None:
"""Save configuration to file."""
click.secho("Saving Configuration", fg="cyan", bold=True)
click.secho("-" * 30, fg="cyan")
config = AssistantConfig.load()
config.set_provider(provider.name, model)
config.providers[provider.name].skills = skills_config
config.save()
click.secho("Configuration saved", fg="green")
click.echo()
def _show_init_success(provider: AssistantProvider, model: str, skill_path: Path) -> None:
"""Show success message and next steps."""
click.secho(" ~ * ~ * ~ * ~ * ~ * ~ * ~ * ~", fg="green")
click.secho(" Setup Complete! ", fg="green", bold=True)
click.secho(" ~ * ~ * ~ * ~ * ~ * ~ * ~ * ~", fg="green")
click.echo()
click.secho("Configuration:", bold=True)
click.echo(f" Provider: {provider.display_name}")
click.echo(f" Model: {model}")
click.echo(f" Skills: {skill_path}")
click.echo()
click.secho("Next steps:", bold=True)
click.echo(" 1. Start MLflow server:")
click.secho(" $ mlflow server", fg="cyan")
click.echo()
click.echo(" 2. Open MLflow UI and navigate to an experiment")
click.echo()
click.echo(" 3. Click 'Ask Assistant'")
click.echo()
click.secho("To reconfigure, run: ", nl=False)
click.secho("mlflow assistant --configure", fg="cyan")