devray-datalab-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """DevRay DataLab MCP - Local-first data science and ML MCP server."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,72 @@
1
+ """Server configuration.
2
+
3
+ Settings are read from environment variables with sensible defaults so the
4
+ server runs on modest hardware without any cloud credentials.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+
14
+ def _env_str(name: str, default: str) -> str:
15
+ return os.environ.get(name, default)
16
+
17
+
18
+ def _env_int(name: str, default: int) -> int:
19
+ try:
20
+ return int(os.environ.get(name, default))
21
+ except (TypeError, ValueError):
22
+ return default
23
+
24
+
25
+ def _env_float(name: str, default: float) -> float:
26
+ try:
27
+ return float(os.environ.get(name, default))
28
+ except (TypeError, ValueError):
29
+ return default
30
+
31
+
32
+ def _env_bool(name: str, default: bool) -> bool:
33
+ return os.environ.get(name, str(default)).lower() in ("1", "true", "yes", "on")
34
+
35
+
36
+ @dataclass
37
+ class DevRayConfig:
38
+ """Runtime configuration for the DevRay DataLab MCP server."""
39
+
40
+ # Filesystem locations.
41
+ workspace_dir: Path = field(
42
+ default_factory=lambda: Path(_env_devray("DEVRAY_WORKSPACE_DIR", "datasets"))
43
+ )
44
+ artifacts_dir: Path = field(
45
+ default_factory=lambda: Path(_env_devray("DEVRAY_ARTIFACTS_DIR", "artifacts"))
46
+ )
47
+
48
+ # Dataset limits.
49
+ max_dataset_size_bytes: int = _env_int("DEVRAY_MAX_DATASET_SIZE_BYTES", 200 * 1024 * 1024)
50
+ allowed_extensions: tuple[str, ...] = (".csv",)
51
+
52
+ # Preview limits.
53
+ max_preview_rows: int = _env_int("DEVRAY_MAX_PREVIEW_ROWS", 25)
54
+
55
+ # Training / resource limits.
56
+ max_cpu_threads: int | None = _env_int("DEVRAY_MAX_CPU_THREADS", 0) or None
57
+ training_memory_budget_mb: int | None = _env_int("DEVRAY_TRAINING_MEMORY_BUDGET_MB", 0) or None
58
+ max_concurrent_jobs: int = _env_int("DEVRAY_MAX_CONCURRENT_JOBS", 1)
59
+ training_timeout_seconds: int = _env_int("DEVRAY_TRAINING_TIMEOUT_SECONDS", 1800)
60
+
61
+ # Logging.
62
+ log_level: str = _env_str("DEVRAY_LOG_LEVEL", "INFO")
63
+
64
+ def __post_init__(self) -> None:
65
+ self.workspace_dir = self.workspace_dir.resolve()
66
+ self.artifacts_dir = self.artifacts_dir.resolve()
67
+ self.workspace_dir.mkdir(parents=True, exist_ok=True)
68
+ self.artifacts_dir.mkdir(parents=True, exist_ok=True)
69
+
70
+
71
+ def _env_devray(name: str, default: str) -> str:
72
+ return os.environ.get(name, default)
@@ -0,0 +1 @@
1
+ """Core ML utilities package."""
@@ -0,0 +1,145 @@
1
+ """Evaluation metrics for classification and regression.
2
+
3
+ Metrics are computed with scikit-learn and include clear explanations so
4
+ clients can interpret results without guessing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ from sklearn.metrics import (
14
+ accuracy_score,
15
+ f1_score,
16
+ mean_absolute_error,
17
+ mean_squared_error,
18
+ precision_score,
19
+ r2_score,
20
+ recall_score,
21
+ roc_auc_score,
22
+ )
23
+
24
+
25
+ @dataclass
26
+ class ClassificationMetrics:
27
+ accuracy: float
28
+ precision: float
29
+ recall: float
30
+ f1: float
31
+ roc_auc: float | None
32
+ support: int
33
+ explanation: str
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ return {
37
+ "accuracy": round(self.accuracy, 6),
38
+ "precision": round(self.precision, 6),
39
+ "recall": round(self.recall, 6),
40
+ "f1": round(self.f1, 6),
41
+ "roc_auc": None if self.roc_auc is None else round(self.roc_auc, 6),
42
+ "support": self.support,
43
+ "explanation": self.explanation,
44
+ }
45
+
46
+
47
+ @dataclass
48
+ class RegressionMetrics:
49
+ mae: float
50
+ rmse: float
51
+ r2: float
52
+ support: int
53
+ explanation: str
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ return {
57
+ "mae": round(self.mae, 6),
58
+ "rmse": round(self.rmse, 6),
59
+ "r2": round(self.r2, 6),
60
+ "support": self.support,
61
+ "explanation": self.explanation,
62
+ }
63
+
64
+
65
+ def classification_explanation() -> str:
66
+ return (
67
+ "Accuracy = fraction of correct predictions. Precision = fraction of "
68
+ "positive predictions that are correct. Recall = fraction of actual "
69
+ "positives found. F1 = harmonic mean of precision and recall. "
70
+ "ROC-AUC = ability to rank positives above negatives (binary only)."
71
+ )
72
+
73
+
74
+ def regression_explanation() -> str:
75
+ return (
76
+ "MAE = average absolute error (same units as target). RMSE = root mean "
77
+ "squared error (penalizes large errors). R^2 = fraction of target "
78
+ "variance explained (1 = perfect, 0 = mean baseline, negative = worse "
79
+ "than mean)."
80
+ )
81
+
82
+
83
+ def evaluate_classification(
84
+ y_true: np.ndarray, y_pred: np.ndarray, y_proba: np.ndarray | None = None
85
+ ) -> ClassificationMetrics:
86
+ """Compute classification metrics with zero-division safety."""
87
+ average = "binary" if len(np.unique(y_true)) == 2 else "macro"
88
+ zero_division = 0
89
+
90
+ try:
91
+ roc_auc: float | None = None
92
+ if y_proba is not None and len(np.unique(y_true)) == 2:
93
+ # Use probability of the positive class when available.
94
+ proba = np.asarray(y_proba)
95
+ if proba.ndim == 2 and proba.shape[1] >= 2:
96
+ roc_auc = float(roc_auc_score(y_true, proba[:, 1]))
97
+ elif proba.ndim == 1:
98
+ roc_auc = float(roc_auc_score(y_true, proba))
99
+ except Exception: # noqa: BLE001
100
+ roc_auc = None
101
+
102
+ return ClassificationMetrics(
103
+ accuracy=float(accuracy_score(y_true, y_pred)),
104
+ precision=float(
105
+ precision_score(y_true, y_pred, average=average, zero_division=zero_division)
106
+ ),
107
+ recall=float(recall_score(y_true, y_pred, average=average, zero_division=zero_division)),
108
+ f1=float(f1_score(y_true, y_pred, average=average, zero_division=zero_division)),
109
+ roc_auc=roc_auc,
110
+ support=int(len(y_true)),
111
+ explanation=classification_explanation(),
112
+ )
113
+
114
+
115
+ def evaluate_regression(y_true: np.ndarray, y_pred: np.ndarray) -> RegressionMetrics:
116
+ """Compute regression metrics."""
117
+ mae = float(mean_absolute_error(y_true, y_pred))
118
+ rmse = float(np.sqrt(mean_squared_error(y_true, y_pred)))
119
+ r2 = float(r2_score(y_true, y_pred))
120
+ return RegressionMetrics(
121
+ mae=mae,
122
+ rmse=rmse,
123
+ r2=r2,
124
+ support=int(len(y_true)),
125
+ explanation=regression_explanation(),
126
+ )
127
+
128
+
129
+ def cross_validate_classification(
130
+ estimator: Any,
131
+ X: np.ndarray,
132
+ y: np.ndarray,
133
+ cv: int = 5,
134
+ scoring: str = "accuracy",
135
+ ) -> dict[str, float]:
136
+ """Run stratified cross-validation and return mean/std per metric."""
137
+ from sklearn.model_selection import StratifiedKFold, cross_validate
138
+
139
+ cv_splitter = StratifiedKFold(n_splits=min(cv, len(np.unique(y))), shuffle=True)
140
+ scores = cross_validate(estimator, X, y, cv=cv_splitter, scoring=scoring, n_jobs=1)
141
+ key = f"test_{scoring}"
142
+ return {
143
+ "mean": float(np.mean(scores[key])),
144
+ "std": float(np.std(scores[key])),
145
+ }
@@ -0,0 +1,104 @@
1
+ """Hardware and resource detection.
2
+
3
+ Reports CPU, memory, storage and GPU availability using only psutil and the
4
+ standard library. No PyTorch, TensorFlow, or CUDA dependencies are required.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import platform
11
+ import shutil
12
+ from dataclasses import dataclass, field
13
+
14
+ import psutil
15
+
16
+ from devray_datalab.utils.errors import HardwareError
17
+
18
+
19
+ @dataclass
20
+ class GpuInfo:
21
+ """Detected GPU information."""
22
+
23
+ detected: bool = False
24
+ detail: str = "No supported GPU detected"
25
+ backend_available: bool = False
26
+
27
+
28
+ @dataclass
29
+ class SystemInfo:
30
+ operating_system: str
31
+ os_version: str
32
+ processor: str
33
+ cpu_cores_logical: int
34
+ cpu_cores_physical: int
35
+ cpu_percent: float
36
+ ram_total_gb: float
37
+ ram_available_gb: float
38
+ ram_percent: float
39
+ storage_total_gb: float
40
+ storage_free_gb: float
41
+ storage_used_percent: float
42
+ gpu: GpuInfo = field(default_factory=GpuInfo)
43
+ sklearn_cpu_backend: bool = True
44
+
45
+
46
+ def _detect_gpu() -> GpuInfo:
47
+ """Best-effort GPU detection without GPU dependencies.
48
+
49
+ Checks common GPU indicators exposed by psutil/the OS and reports whether
50
+ a GPU-accelerated sklearn backend appears to be installed.
51
+ """
52
+ detail_parts: list[str] = []
53
+ detected = False
54
+
55
+ # NVIDIA GPU hint via nvidia-smi availability.
56
+ nvidia_smi = shutil.which("nvidia-smi")
57
+ if nvidia_smi:
58
+ detected = True
59
+ detail_parts.append("NVIDIA GPU (nvidia-smi available)")
60
+
61
+ # Try to detect GPU via py3nvml if present (optional, no hard dep).
62
+ try:
63
+ import py3nvml # type: ignore
64
+
65
+ try:
66
+ py3nvml.nvmlInit()
67
+ count = py3nvml.nvmlDeviceGetCount()
68
+ if count > 0:
69
+ detected = True
70
+ detail_parts.append(f"NVIDIA GPU(s) via NVML: {count}")
71
+ except Exception:
72
+ pass
73
+ except ImportError:
74
+ pass
75
+
76
+ detail = "; ".join(detail_parts) if detail_parts else "No supported GPU detected"
77
+ return GpuInfo(detected=detected, detail=detail)
78
+
79
+
80
+ def get_system_info() -> SystemInfo:
81
+ """Gather system hardware information."""
82
+ try:
83
+ vm = psutil.virtual_memory()
84
+ cpu_percent = psutil.cpu_percent(interval=0.1)
85
+ disk = shutil.disk_usage(os.getcwd())
86
+ gpu = _detect_gpu()
87
+
88
+ return SystemInfo(
89
+ operating_system=platform.system(),
90
+ os_version=platform.version(),
91
+ processor=platform.processor() or platform.machine(),
92
+ cpu_cores_logical=psutil.cpu_count(logical=True) or 1,
93
+ cpu_cores_physical=psutil.cpu_count(logical=False) or 1,
94
+ cpu_percent=cpu_percent,
95
+ ram_total_gb=round(vm.total / (1024**3), 2),
96
+ ram_available_gb=round(vm.available / (1024**3), 2),
97
+ ram_percent=round(vm.percent, 1),
98
+ storage_total_gb=round(disk.total / (1024**3), 2),
99
+ storage_free_gb=round(disk.free / (1024**3), 2),
100
+ storage_used_percent=round(disk.used / disk.total * 100, 1),
101
+ gpu=gpu,
102
+ )
103
+ except Exception as exc: # noqa: BLE001
104
+ raise HardwareError(f"Failed to gather system info: {exc}") from exc
@@ -0,0 +1,71 @@
1
+ """Dataset loading utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+
10
+ from devray_datalab.config import DevRayConfig
11
+ from devray_datalab.utils.errors import DatasetError
12
+ from devray_datalab.utils.validation import (
13
+ MAX_PREVIEW_ROWS,
14
+ validate_dataset_path,
15
+ validate_file_size,
16
+ )
17
+
18
+
19
+ def list_datasets(workspace: Path) -> list[dict[str, Any]]:
20
+ """List datasets in the workspace with filenames, formats, and sizes."""
21
+ if not workspace.is_dir():
22
+ return []
23
+
24
+ datasets: list[dict[str, Any]] = []
25
+ for entry in sorted(workspace.iterdir()):
26
+ if not entry.is_file():
27
+ continue
28
+ if entry.suffix.lower() not in (".csv",):
29
+ continue
30
+ try:
31
+ stat = entry.stat()
32
+ except OSError:
33
+ continue
34
+ datasets.append(
35
+ {
36
+ "filename": entry.name,
37
+ "format": entry.suffix.lower().lstrip("."),
38
+ "size_bytes": stat.st_size,
39
+ "size_mb": round(stat.st_size / (1024 * 1024), 3),
40
+ }
41
+ )
42
+ return datasets
43
+
44
+
45
+ def load_dataset(config: DevRayConfig, name: str) -> pd.DataFrame:
46
+ """Load a CSV dataset from the workspace, validating path and size."""
47
+ path = validate_dataset_path(config.workspace_dir, name, must_exist=True)
48
+ validate_file_size(path, config.max_dataset_size_bytes)
49
+
50
+ try:
51
+ df = pd.read_csv(path)
52
+ except Exception as exc: # noqa: BLE001 - surface a clear dataset error
53
+ raise DatasetError(f"Failed to load dataset '{name}': {exc}") from exc
54
+
55
+ if df.empty:
56
+ raise DatasetError(f"Dataset '{name}' is empty.")
57
+ return df
58
+
59
+
60
+ def preview_dataframe(df: pd.DataFrame, max_rows: int = MAX_PREVIEW_ROWS) -> list[dict[str, Any]]:
61
+ """Return the first ``max_rows`` rows as a list of dicts."""
62
+ return df.head(max_rows).to_dict(orient="records")
63
+
64
+
65
+ def dataset_statistics(df: pd.DataFrame) -> dict[str, Any]:
66
+ """Compute numerical statistics for a dataframe."""
67
+ numeric = df.select_dtypes(include="number")
68
+ if numeric.empty:
69
+ return {}
70
+ stats = numeric.describe().to_dict()
71
+ return stats
@@ -0,0 +1,115 @@
1
+ """Preprocessing pipelines built from scikit-learn transformers.
2
+
3
+ Transformations are fitted on training data only and preserved for later
4
+ prediction, preventing data leakage.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ from sklearn.compose import ColumnTransformer
15
+ from sklearn.impute import SimpleImputer
16
+ from sklearn.pipeline import Pipeline
17
+ from sklearn.preprocessing import OneHotEncoder, StandardScaler
18
+
19
+ from devray_datalab.utils.errors import ValidationError
20
+
21
+
22
+ @dataclass
23
+ class PreprocessingArtifact:
24
+ """A fitted preprocessing pipeline plus the schema it was fitted on."""
25
+
26
+ preprocessor: ColumnTransformer
27
+ feature_columns: list[str]
28
+ target_column: str
29
+ task_type: str
30
+ numeric_features: list[str] = field(default_factory=list)
31
+ categorical_features: list[str] = field(default_factory=list)
32
+
33
+
34
+ def _infer_feature_types(df: pd.DataFrame, target_column: str) -> tuple[list[str], list[str]]:
35
+ """Split feature columns into numeric and categorical lists."""
36
+ if target_column not in df.columns:
37
+ raise ValidationError(f"Target column '{target_column}' not found in dataset.")
38
+ features = [c for c in df.columns if c != target_column]
39
+
40
+ numeric_features: list[str] = []
41
+ categorical_features: list[str] = []
42
+ for col in features:
43
+ if pd.api.types.is_numeric_dtype(df[col]):
44
+ numeric_features.append(col)
45
+ else:
46
+ categorical_features.append(col)
47
+ return numeric_features, categorical_features
48
+
49
+
50
+ def build_preprocessor(
51
+ df: pd.DataFrame,
52
+ target_column: str,
53
+ task_type: str,
54
+ impute_strategy: str = "mean",
55
+ scale_numeric: bool = True,
56
+ encode_categorical: bool = True,
57
+ ) -> PreprocessingArtifact:
58
+ """Build and fit a preprocessing pipeline on ``df`` (training data only).
59
+
60
+ - Numeric columns: impute (median for robustness) then optionally standard-scale.
61
+ - Categorical columns: one-hot encode with unknown categories ignored.
62
+ """
63
+ numeric_features, categorical_features = _infer_feature_types(df, target_column)
64
+
65
+ transformers: list[tuple[str, Any, list[str]]] = []
66
+
67
+ if numeric_features:
68
+ numeric_steps: list[tuple[str, Any]] = [("imputer", SimpleImputer(strategy="median"))]
69
+ if scale_numeric:
70
+ numeric_steps.append(("scaler", StandardScaler()))
71
+ transformers.append(("num", Pipeline(steps=numeric_steps), numeric_features))
72
+
73
+ if categorical_features:
74
+ encode_steps: list[tuple[str, Any]] = [("imputer", SimpleImputer(strategy="most_frequent"))]
75
+ if encode_categorical:
76
+ encode_steps.append(
77
+ (
78
+ "onehot",
79
+ OneHotEncoder(handle_unknown="ignore", sparse_output=False),
80
+ )
81
+ )
82
+ transformers.append(("cat", Pipeline(steps=encode_steps), categorical_features))
83
+
84
+ if not transformers:
85
+ raise ValidationError("No feature columns available for preprocessing.")
86
+
87
+ preprocessor = ColumnTransformer(transformers=transformers, remainder="drop")
88
+ feature_columns = [c for c in df.columns if c != target_column]
89
+ preprocessor.fit(df[feature_columns])
90
+
91
+ return PreprocessingArtifact(
92
+ preprocessor=preprocessor,
93
+ feature_columns=feature_columns,
94
+ target_column=target_column,
95
+ task_type=task_type,
96
+ numeric_features=numeric_features,
97
+ categorical_features=categorical_features,
98
+ )
99
+
100
+
101
+ def transform_features(artifact: PreprocessingArtifact, df: pd.DataFrame) -> np.ndarray:
102
+ """Transform a dataframe using a fitted preprocessing artifact."""
103
+ missing = [c for c in artifact.feature_columns if c not in df.columns]
104
+ if missing:
105
+ raise ValidationError(f"Missing feature columns: {', '.join(missing)}")
106
+ return artifact.preprocessor.transform(df[artifact.feature_columns])
107
+
108
+
109
+ def get_feature_names_out(artifact: PreprocessingArtifact) -> list[str]:
110
+ """Return the output feature names after preprocessing."""
111
+ try:
112
+ names = artifact.preprocessor.get_feature_names_out()
113
+ return [str(n) for n in names]
114
+ except Exception: # pragma: no cover - fallback for older sklearn
115
+ return list(artifact.feature_columns)