QuickLab-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.
Files changed (92) hide show
  1. ml_mcp/__init__.py +3 -0
  2. ml_mcp/application/__init__.py +1 -0
  3. ml_mcp/application/analysis/__init__.py +15 -0
  4. ml_mcp/application/analysis/diagnostics.py +122 -0
  5. ml_mcp/application/analysis/service.py +155 -0
  6. ml_mcp/application/artifacts/__init__.py +5 -0
  7. ml_mcp/application/artifacts/service.py +149 -0
  8. ml_mcp/application/datasets/__init__.py +6 -0
  9. ml_mcp/application/datasets/service.py +178 -0
  10. ml_mcp/application/datasets/validator.py +156 -0
  11. ml_mcp/application/experiments/__init__.py +5 -0
  12. ml_mcp/application/experiments/service.py +254 -0
  13. ml_mcp/application/models/__init__.py +6 -0
  14. ml_mcp/application/models/catalog_seed.py +131 -0
  15. ml_mcp/application/models/service.py +177 -0
  16. ml_mcp/application/projects/__init__.py +5 -0
  17. ml_mcp/application/projects/service.py +93 -0
  18. ml_mcp/config/__init__.py +5 -0
  19. ml_mcp/config/settings.py +176 -0
  20. ml_mcp/domain/__init__.py +1 -0
  21. ml_mcp/domain/errors/__init__.py +39 -0
  22. ml_mcp/domain/errors/base.py +155 -0
  23. ml_mcp/domain/errors/codes.py +23 -0
  24. ml_mcp/domain/policies/__init__.py +16 -0
  25. ml_mcp/domain/policies/rbac.py +126 -0
  26. ml_mcp/domain/policies/tenant_isolation.py +34 -0
  27. ml_mcp/domain/policies/tool_policy.py +185 -0
  28. ml_mcp/domain/value_objects/__init__.py +41 -0
  29. ml_mcp/domain/value_objects/dataset.py +60 -0
  30. ml_mcp/domain/value_objects/experiment.py +131 -0
  31. ml_mcp/domain/value_objects/model.py +48 -0
  32. ml_mcp/infrastructure/__init__.py +1 -0
  33. ml_mcp/infrastructure/object_storage/__init__.py +5 -0
  34. ml_mcp/infrastructure/object_storage/s3.py +272 -0
  35. ml_mcp/infrastructure/postgres/__init__.py +14 -0
  36. ml_mcp/infrastructure/postgres/base.py +40 -0
  37. ml_mcp/infrastructure/postgres/models.py +356 -0
  38. ml_mcp/infrastructure/postgres/repositories/__init__.py +21 -0
  39. ml_mcp/infrastructure/postgres/repositories/artifacts.py +34 -0
  40. ml_mcp/infrastructure/postgres/repositories/audit.py +34 -0
  41. ml_mcp/infrastructure/postgres/repositories/datasets.py +85 -0
  42. ml_mcp/infrastructure/postgres/repositories/experiments.py +134 -0
  43. ml_mcp/infrastructure/postgres/repositories/metrics.py +61 -0
  44. ml_mcp/infrastructure/postgres/repositories/models.py +61 -0
  45. ml_mcp/infrastructure/postgres/repositories/outbox.py +47 -0
  46. ml_mcp/infrastructure/postgres/repositories/projects.py +83 -0
  47. ml_mcp/infrastructure/postgres/session.py +117 -0
  48. ml_mcp/infrastructure/queue/__init__.py +6 -0
  49. ml_mcp/infrastructure/queue/outbox_processor.py +67 -0
  50. ml_mcp/infrastructure/queue/task_queue.py +98 -0
  51. ml_mcp/infrastructure/redis/__init__.py +11 -0
  52. ml_mcp/infrastructure/redis/client.py +64 -0
  53. ml_mcp/infrastructure/redis/rate_limiter.py +129 -0
  54. ml_mcp/infrastructure/telemetry/__init__.py +23 -0
  55. ml_mcp/infrastructure/telemetry/logging.py +155 -0
  56. ml_mcp/infrastructure/telemetry/otel.py +141 -0
  57. ml_mcp/mcp/__init__.py +1 -0
  58. ml_mcp/mcp/prompts.py +38 -0
  59. ml_mcp/mcp/resources.py +35 -0
  60. ml_mcp/mcp/schemas/__init__.py +1 -0
  61. ml_mcp/mcp/schemas/tools.py +124 -0
  62. ml_mcp/mcp/tools/__init__.py +63 -0
  63. ml_mcp/mcp/tools/analysis.py +37 -0
  64. ml_mcp/mcp/tools/datasets.py +82 -0
  65. ml_mcp/mcp/tools/discovery.py +82 -0
  66. ml_mcp/mcp/tools/experiments.py +96 -0
  67. ml_mcp/mcp/tools/projects.py +39 -0
  68. ml_mcp/mcp/tools/results.py +59 -0
  69. ml_mcp/server/__init__.py +1 -0
  70. ml_mcp/server/app.py +488 -0
  71. ml_mcp/server/auth.py +146 -0
  72. ml_mcp/server/context.py +59 -0
  73. ml_mcp/server/http.py +183 -0
  74. ml_mcp/server/middleware.py +121 -0
  75. ml_mcp/server/stdio.py +42 -0
  76. ml_mcp/workers/__init__.py +1 -0
  77. ml_mcp/workers/daemon.py +130 -0
  78. ml_mcp/workers/metrics.py +110 -0
  79. ml_mcp/workers/preprocessing.py +170 -0
  80. ml_mcp/workers/runner.py +250 -0
  81. ml_mcp/workers/sandbox.py +35 -0
  82. ml_mcp/workers/trainers/__init__.py +52 -0
  83. ml_mcp/workers/trainers/base.py +52 -0
  84. ml_mcp/workers/trainers/catboost_model.py +85 -0
  85. ml_mcp/workers/trainers/lightgbm_model.py +81 -0
  86. ml_mcp/workers/trainers/sklearn_models.py +266 -0
  87. ml_mcp/workers/trainers/xgboost_model.py +82 -0
  88. quicklab_mcp-0.1.0.dist-info/METADATA +540 -0
  89. quicklab_mcp-0.1.0.dist-info/RECORD +92 -0
  90. quicklab_mcp-0.1.0.dist-info/WHEEL +5 -0
  91. quicklab_mcp-0.1.0.dist-info/entry_points.txt +4 -0
  92. quicklab_mcp-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,272 @@
1
+ """S3-compatible object storage service with tenant isolation and presigned URL support."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import re
7
+ from typing import Any
8
+
9
+ import boto3
10
+ from botocore.client import Config
11
+ from botocore.exceptions import ClientError
12
+
13
+ from ml_mcp.config import Settings, get_settings
14
+ from ml_mcp.domain.errors import DependencyUnavailableError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class S3StorageService:
20
+ """Production S3 / MinIO storage adapter managing artifacts and datasets."""
21
+
22
+ _SAFE_COMPONENT_PATTERN = re.compile(r"^[a-zA-Z0-9_\-\.]+$")
23
+
24
+ def __init__(self, settings: Settings | None = None) -> None:
25
+ self.settings = settings or get_settings()
26
+ self.bucket_name = self.settings.object_store.bucket_name
27
+ self._client: Any = None
28
+ self._memory_store: dict[str, tuple[bytes, dict[str, str], str]] = {}
29
+
30
+ @classmethod
31
+ def _assert_safe_component(cls, component: str, field_name: str = "component") -> str:
32
+ """Validate that a path component contains no traversal sequences or unsafe characters."""
33
+ if not component or not isinstance(component, str) or not component.strip():
34
+ raise ValueError(f"Invalid {field_name}: empty component")
35
+ if component in (".", "..") or ".." in component or "/" in component or "\\" in component:
36
+ raise ValueError(f"Directory traversal detected in {field_name}: '{component}'")
37
+ if not cls._SAFE_COMPONENT_PATTERN.match(component):
38
+ raise ValueError(f"Invalid characters detected in {field_name}: '{component}'")
39
+ return component
40
+
41
+ @classmethod
42
+ def _assert_safe_key(cls, key: str) -> str:
43
+ """Validate full storage key against path traversal and malformed structures."""
44
+ if not key or not isinstance(key, str) or not key.strip():
45
+ raise ValueError("Invalid storage key: empty key")
46
+ if key.startswith("/") or key.endswith("/"):
47
+ raise ValueError(f"Invalid storage key: leading or trailing slash in '{key}'")
48
+ if "//" in key or "\\" in key or ".." in key:
49
+ raise ValueError(f"Directory traversal detected in storage key: '{key}'")
50
+ parts = key.split("/")
51
+ for part in parts:
52
+ cls._assert_safe_component(part, "key component")
53
+ return key
54
+
55
+ @property
56
+ def is_testing(self) -> bool:
57
+ app_env = getattr(self.settings, "app_env", None)
58
+ if app_env is None and hasattr(self.settings, "app"):
59
+ app_env = getattr(self.settings.app, "env", None)
60
+ return app_env == "testing" or os.environ.get("APP_ENV") == "testing"
61
+
62
+ @property
63
+ def client(self) -> Any:
64
+ if self._client is None:
65
+ self._client = boto3.client(
66
+ "s3",
67
+ endpoint_url=self.settings.object_store.endpoint_url,
68
+ aws_access_key_id=self.settings.object_store.access_key_id.get_secret_value(),
69
+ aws_secret_access_key=self.settings.object_store.secret_access_key.get_secret_value(),
70
+ region_name=self.settings.object_store.region,
71
+ config=Config(
72
+ signature_version="s3v4",
73
+ s3={"addressing_style": "path"},
74
+ connect_timeout=1,
75
+ read_timeout=1,
76
+ ),
77
+ )
78
+ return self._client
79
+
80
+ @staticmethod
81
+ def compute_sha256(data: bytes) -> str:
82
+ """Compute standard hex SHA-256 digest of binary content."""
83
+ return hashlib.sha256(data).hexdigest()
84
+
85
+ # Tenant-isolated path generators
86
+ @classmethod
87
+ def get_dataset_key(
88
+ cls, tenant_id: str, dataset_id: str, version: str, filename: str = "data.parquet"
89
+ ) -> str:
90
+ cls._assert_safe_component(tenant_id, "tenant_id")
91
+ cls._assert_safe_component(dataset_id, "dataset_id")
92
+ cls._assert_safe_component(version, "version")
93
+ cls._assert_safe_component(filename, "filename")
94
+ return f"tenants/{tenant_id}/datasets/{dataset_id}/{version}/{filename}"
95
+
96
+ @classmethod
97
+ def get_experiment_artifact_key(
98
+ cls, tenant_id: str, experiment_id: str, artifact_type: str, filename: str
99
+ ) -> str:
100
+ cls._assert_safe_component(tenant_id, "tenant_id")
101
+ cls._assert_safe_component(experiment_id, "experiment_id")
102
+ cls._assert_safe_component(artifact_type, "artifact_type")
103
+ cls._assert_safe_component(filename, "filename")
104
+ return f"tenants/{tenant_id}/experiments/{experiment_id}/{artifact_type}/{filename}"
105
+
106
+ @classmethod
107
+ def get_model_key(
108
+ cls, tenant_id: str, model_id: str, version: str, filename: str = "model.joblib"
109
+ ) -> str:
110
+ cls._assert_safe_component(tenant_id, "tenant_id")
111
+ cls._assert_safe_component(model_id, "model_id")
112
+ cls._assert_safe_component(version, "version")
113
+ cls._assert_safe_component(filename, "filename")
114
+ return f"tenants/{tenant_id}/models/{model_id}/{version}/{filename}"
115
+
116
+ def put_object(
117
+ self,
118
+ key: str,
119
+ data: bytes,
120
+ content_type: str = "application/octet-stream",
121
+ metadata: dict[str, str] | None = None,
122
+ ) -> tuple[str, str]:
123
+ """Upload an immutable object. Returns (storage_uri, sha256_digest)."""
124
+ self._assert_safe_key(key)
125
+ content_hash = self.compute_sha256(data)
126
+ meta = metadata or {}
127
+ meta["content_hash"] = content_hash
128
+
129
+ if self.is_testing and self._client is None:
130
+ self._memory_store[key] = (data, meta, content_type)
131
+ storage_uri = f"s3://{self.bucket_name}/{key}"
132
+ logger.info(
133
+ "Testing environment: stored S3 object in memory: %s (size: %d bytes)",
134
+ storage_uri,
135
+ len(data),
136
+ )
137
+ return storage_uri, content_hash
138
+
139
+ try:
140
+ self.client.put_object(
141
+ Bucket=self.bucket_name,
142
+ Key=key,
143
+ Body=data,
144
+ ContentType=content_type,
145
+ Metadata=meta,
146
+ )
147
+ storage_uri = f"s3://{self.bucket_name}/{key}"
148
+ logger.info("Successfully wrote S3 object: %s (size: %d bytes)", storage_uri, len(data))
149
+ return storage_uri, content_hash
150
+ except Exception as exc:
151
+ if not self.is_testing:
152
+ raise DependencyUnavailableError("Object Storage") from exc
153
+ logger.warning(
154
+ "Live S3 unavailable (%s), writing object to memory fallback for key '%s'", exc, key
155
+ )
156
+ self._memory_store[key] = (data, meta, content_type)
157
+ storage_uri = f"s3://{self.bucket_name}/{key}"
158
+ return storage_uri, content_hash
159
+
160
+ def get_object(self, key: str) -> bytes:
161
+ """Retrieve binary content of an object."""
162
+ self._assert_safe_key(key)
163
+ if key in self._memory_store:
164
+ return self._memory_store[key][0]
165
+ if self.is_testing and self._client is None:
166
+ logger.error("Failed to read object from S3 memory store at key '%s'", key)
167
+ raise DependencyUnavailableError("Object Storage")
168
+ try:
169
+ response = self.client.get_object(Bucket=self.bucket_name, Key=key)
170
+ return response["Body"].read()
171
+ except Exception as exc:
172
+ if key in self._memory_store:
173
+ return self._memory_store[key][0]
174
+ logger.error("Failed to read object from S3 at key '%s': %s", key, exc)
175
+ raise DependencyUnavailableError("Object Storage") from exc
176
+
177
+ def head_object(self, key: str) -> dict[str, Any] | None:
178
+ """Inspect object metadata without downloading full payload."""
179
+ self._assert_safe_key(key)
180
+ if key in self._memory_store:
181
+ data, meta, c_type = self._memory_store[key]
182
+ return {
183
+ "size_bytes": len(data),
184
+ "content_type": c_type,
185
+ "metadata": meta,
186
+ "last_modified": None,
187
+ }
188
+ if self.is_testing and self._client is None:
189
+ return None
190
+ try:
191
+ response = self.client.head_object(Bucket=self.bucket_name, Key=key)
192
+ return {
193
+ "size_bytes": response.get("ContentLength", 0),
194
+ "content_type": response.get("ContentType"),
195
+ "metadata": response.get("Metadata", {}),
196
+ "last_modified": response.get("LastModified"),
197
+ }
198
+ except ClientError as exc:
199
+ if exc.response["Error"]["Code"] in ("404", "NoSuchKey"):
200
+ return None
201
+ raise DependencyUnavailableError("Object Storage") from exc
202
+ except Exception as exc:
203
+ if key in self._memory_store:
204
+ data, meta, c_type = self._memory_store[key]
205
+ return {
206
+ "size_bytes": len(data),
207
+ "content_type": c_type,
208
+ "metadata": meta,
209
+ "last_modified": None,
210
+ }
211
+ raise DependencyUnavailableError("Object Storage") from exc
212
+
213
+ def delete_object(self, key: str) -> bool:
214
+ """Delete an object."""
215
+ self._assert_safe_key(key)
216
+ self._memory_store.pop(key, None)
217
+ if self.is_testing and self._client is None:
218
+ return True
219
+ try:
220
+ self.client.delete_object(Bucket=self.bucket_name, Key=key)
221
+ return True
222
+ except Exception as exc:
223
+ logger.warning("Failed to delete object from S3: %s", exc)
224
+ return True
225
+
226
+ def generate_presigned_get_url(self, key: str, expires_in: int | None = None) -> str:
227
+ """Generate a short-lived read-only presigned GET URL."""
228
+ self._assert_safe_key(key)
229
+ ttl = expires_in or self.settings.object_store.presigned_ttl_seconds
230
+ if self.is_testing and self._client is None:
231
+ return f"https://s3.local/{self.bucket_name}/{key}?expires={ttl}&sig=mock"
232
+ try:
233
+ url = self.client.generate_presigned_url(
234
+ ClientMethod="get_object",
235
+ Params={"Bucket": self.bucket_name, "Key": key},
236
+ ExpiresIn=ttl,
237
+ )
238
+ return url
239
+ except Exception:
240
+ return f"https://s3.local/{self.bucket_name}/{key}?expires={ttl}&sig=mock"
241
+
242
+ def generate_presigned_put_url(
243
+ self,
244
+ key: str,
245
+ content_type: str = "application/octet-stream",
246
+ expires_in: int | None = None,
247
+ ) -> str:
248
+ """Generate a short-lived presigned PUT URL for direct client uploads."""
249
+ self._assert_safe_key(key)
250
+ ttl = expires_in or self.settings.object_store.presigned_ttl_seconds
251
+ if self.is_testing and self._client is None:
252
+ return f"https://s3.local/{self.bucket_name}/{key}?expires={ttl}&sig=mock"
253
+ try:
254
+ url = self.client.generate_presigned_url(
255
+ ClientMethod="put_object",
256
+ Params={"Bucket": self.bucket_name, "Key": key, "ContentType": content_type},
257
+ ExpiresIn=ttl,
258
+ )
259
+ return url
260
+ except Exception:
261
+ return f"https://s3.local/{self.bucket_name}/{key}?expires={ttl}&sig=mock"
262
+
263
+
264
+ _storage_service: S3StorageService | None = None
265
+
266
+
267
+ def get_storage_service() -> S3StorageService:
268
+ """Return singleton S3StorageService instance."""
269
+ global _storage_service
270
+ if _storage_service is None:
271
+ _storage_service = S3StorageService()
272
+ return _storage_service
@@ -0,0 +1,14 @@
1
+ """PostgreSQL persistence layer using SQLAlchemy 2."""
2
+
3
+ import ml_mcp.infrastructure.postgres.models as models
4
+ from ml_mcp.infrastructure.postgres.base import Base, generate_uuid7, utc_now
5
+ from ml_mcp.infrastructure.postgres.session import DatabaseManager, get_db_manager
6
+
7
+ __all__ = [
8
+ "Base",
9
+ "generate_uuid7",
10
+ "utc_now",
11
+ "DatabaseManager",
12
+ "get_db_manager",
13
+ "models",
14
+ ]
@@ -0,0 +1,40 @@
1
+ """SQLAlchemy declarative base and time-sortable identifier generator."""
2
+
3
+ import os
4
+ import time
5
+ import uuid
6
+ from datetime import UTC, datetime
7
+
8
+ from sqlalchemy import DateTime
9
+ from sqlalchemy.orm import DeclarativeBase
10
+
11
+
12
+ def generate_uuid7() -> str:
13
+ """Generate a time-sortable UUID (RFC 9562 UUIDv7 format)."""
14
+ # 48-bit timestamp in milliseconds
15
+ ms = int(time.time() * 1000)
16
+ # 12 bits of rand_a + 62 bits of rand_b
17
+ rand_bytes = os.urandom(10)
18
+
19
+ # Construct 16-byte UUID array
20
+ b = bytearray(16)
21
+ b[0:6] = ms.to_bytes(6, byteorder="big")
22
+ b[6] = 0x70 | (rand_bytes[0] & 0x0F) # version 7
23
+ b[7] = rand_bytes[1]
24
+ b[8] = 0x80 | (rand_bytes[2] & 0x3F) # variant 1
25
+ b[9:16] = rand_bytes[3:10]
26
+
27
+ return str(uuid.UUID(bytes=bytes(b)))
28
+
29
+
30
+ def utc_now() -> datetime:
31
+ """Return timezone-aware current UTC datetime."""
32
+ return datetime.now(UTC)
33
+
34
+
35
+ class Base(DeclarativeBase):
36
+ """Declarative base class for all ModelLab ORM entities."""
37
+
38
+ type_annotation_map = {
39
+ datetime: DateTime(timezone=True),
40
+ }
@@ -0,0 +1,356 @@
1
+ """SQLAlchemy 2.x ORM models mapping the complete ModelLab control-plane schema."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from sqlalchemy import (
7
+ JSON,
8
+ BigInteger,
9
+ Float,
10
+ ForeignKey,
11
+ Index,
12
+ Integer,
13
+ String,
14
+ Text,
15
+ UniqueConstraint,
16
+ )
17
+ from sqlalchemy.dialects.postgresql import JSONB
18
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
19
+
20
+ from ml_mcp.infrastructure.postgres.base import Base, generate_uuid7, utc_now
21
+
22
+ # Universal JSON type: native JSONB for PostgreSQL, automatic JSON serializer for SQLite
23
+ JSON_TYPE = JSON().with_variant(JSONB(), "postgresql")
24
+
25
+
26
+ class TenantOrm(Base):
27
+ __tablename__ = "tenants"
28
+
29
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
30
+ name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
31
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
32
+ updated_at: Mapped[datetime] = mapped_column(default=utc_now, onupdate=utc_now, nullable=False)
33
+
34
+ principals: Mapped[list["PrincipalOrm"]] = relationship(
35
+ back_populates="tenant", cascade="all, delete-orphan"
36
+ )
37
+ projects: Mapped[list["ProjectOrm"]] = relationship(
38
+ back_populates="tenant", cascade="all, delete-orphan"
39
+ )
40
+
41
+
42
+ class PrincipalOrm(Base):
43
+ __tablename__ = "principals"
44
+
45
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
46
+ tenant_id: Mapped[str] = mapped_column(
47
+ ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
48
+ )
49
+ username: Mapped[str] = mapped_column(String(128), nullable=False)
50
+ email: Mapped[str] = mapped_column(String(255), nullable=False)
51
+ role: Mapped[str] = mapped_column(String(32), nullable=False, default="viewer")
52
+ scopes: Mapped[list[str]] = mapped_column(JSON_TYPE, default=list, nullable=False)
53
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
54
+
55
+ tenant: Mapped["TenantOrm"] = relationship(back_populates="principals")
56
+
57
+ __table_args__ = (
58
+ UniqueConstraint("tenant_id", "username", name="uq_principals_tenant_username"),
59
+ Index("ix_principals_tenant_created", "tenant_id", "created_at"),
60
+ )
61
+
62
+
63
+ class ProjectOrm(Base):
64
+ __tablename__ = "projects"
65
+
66
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
67
+ tenant_id: Mapped[str] = mapped_column(
68
+ ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
69
+ )
70
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
71
+ description: Mapped[str] = mapped_column(Text, default="", nullable=False)
72
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
73
+
74
+ tenant: Mapped["TenantOrm"] = relationship(back_populates="projects")
75
+ datasets: Mapped[list["DatasetOrm"]] = relationship(
76
+ back_populates="project", cascade="all, delete-orphan"
77
+ )
78
+ experiments: Mapped[list["ExperimentOrm"]] = relationship(
79
+ back_populates="project", cascade="all, delete-orphan"
80
+ )
81
+
82
+ __table_args__ = (
83
+ UniqueConstraint("tenant_id", "name", name="uq_projects_tenant_name"),
84
+ Index("ix_projects_tenant_created", "tenant_id", "created_at"),
85
+ )
86
+
87
+
88
+ class ModelOrm(Base):
89
+ __tablename__ = "models"
90
+
91
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
92
+ tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, default="system")
93
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
94
+ description: Mapped[str] = mapped_column(Text, default="", nullable=False)
95
+ family: Mapped[str] = mapped_column(String(64), nullable=False)
96
+ publisher: Mapped[str] = mapped_column(String(128), default="ModelLab", nullable=False)
97
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
98
+
99
+ versions: Mapped[list["ModelVersionOrm"]] = relationship(
100
+ back_populates="model", cascade="all, delete-orphan"
101
+ )
102
+
103
+ __table_args__ = (Index("ix_models_tenant_created", "tenant_id", "created_at"),)
104
+
105
+
106
+ class ModelVersionOrm(Base):
107
+ __tablename__ = "model_versions"
108
+
109
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
110
+ model_id: Mapped[str] = mapped_column(
111
+ ForeignKey("models.id", ondelete="CASCADE"), nullable=False
112
+ )
113
+ version: Mapped[str] = mapped_column(String(32), nullable=False)
114
+ container_image_digest: Mapped[str] = mapped_column(String(128), nullable=False)
115
+ artifact_digest: Mapped[str | None] = mapped_column(String(128), nullable=True)
116
+ supported_task_types: Mapped[list[str]] = mapped_column(JSON_TYPE, nullable=False)
117
+ hyperparameters_schema: Mapped[list[dict[str, Any]]] = mapped_column(
118
+ JSON_TYPE, default=list, nullable=False
119
+ )
120
+ approval_status: Mapped[str] = mapped_column(String(32), default="APPROVED", nullable=False)
121
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
122
+
123
+ model: Mapped["ModelOrm"] = relationship(back_populates="versions")
124
+
125
+ __table_args__ = (
126
+ UniqueConstraint("model_id", "version", name="uq_model_versions_model_version"),
127
+ Index("ix_model_versions_created", "created_at"),
128
+ )
129
+
130
+
131
+ class DatasetOrm(Base):
132
+ __tablename__ = "datasets"
133
+
134
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
135
+ tenant_id: Mapped[str] = mapped_column(String(36), nullable=False)
136
+ project_id: Mapped[str] = mapped_column(
137
+ ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
138
+ )
139
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
140
+ description: Mapped[str] = mapped_column(Text, default="", nullable=False)
141
+ format: Mapped[str] = mapped_column(String(16), nullable=False)
142
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
143
+
144
+ project: Mapped["ProjectOrm"] = relationship(back_populates="datasets")
145
+ versions: Mapped[list["DatasetVersionOrm"]] = relationship(
146
+ back_populates="dataset", cascade="all, delete-orphan"
147
+ )
148
+
149
+ __table_args__ = (
150
+ UniqueConstraint("project_id", "name", name="uq_datasets_project_name"),
151
+ Index("ix_datasets_tenant_created", "tenant_id", "created_at"),
152
+ Index("ix_datasets_project_created", "project_id", "created_at"),
153
+ )
154
+
155
+
156
+ class DatasetVersionOrm(Base):
157
+ __tablename__ = "dataset_versions"
158
+
159
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
160
+ dataset_id: Mapped[str] = mapped_column(
161
+ ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False
162
+ )
163
+ version: Mapped[str] = mapped_column(String(32), nullable=False)
164
+ schema_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, nullable=False)
165
+ row_count: Mapped[int] = mapped_column(Integer, nullable=False)
166
+ column_count: Mapped[int] = mapped_column(Integer, nullable=False)
167
+ size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
168
+ content_hash: Mapped[str] = mapped_column(String(128), nullable=False)
169
+ storage_uri: Mapped[str] = mapped_column(String(512), nullable=False)
170
+ validation_status: Mapped[str] = mapped_column(String(32), default="VALIDATED", nullable=False)
171
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
172
+
173
+ dataset: Mapped["DatasetOrm"] = relationship(back_populates="versions")
174
+
175
+ __table_args__ = (
176
+ UniqueConstraint("dataset_id", "version", name="uq_dataset_versions_dataset_version"),
177
+ Index("ix_dataset_versions_hash", "content_hash"),
178
+ )
179
+
180
+
181
+ class ExperimentOrm(Base):
182
+ __tablename__ = "experiments"
183
+
184
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
185
+ tenant_id: Mapped[str] = mapped_column(String(36), nullable=False)
186
+ project_id: Mapped[str] = mapped_column(
187
+ ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
188
+ )
189
+ dataset_version_id: Mapped[str] = mapped_column(
190
+ ForeignKey("dataset_versions.id"), nullable=False
191
+ )
192
+ model_version_id: Mapped[str] = mapped_column(ForeignKey("model_versions.id"), nullable=False)
193
+ spec_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, nullable=False)
194
+ status: Mapped[str] = mapped_column(String(32), default="CREATED", nullable=False)
195
+ idempotency_key: Mapped[str | None] = mapped_column(String(128), nullable=True)
196
+ created_by: Mapped[str] = mapped_column(String(128), nullable=False)
197
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
198
+ updated_at: Mapped[datetime] = mapped_column(default=utc_now, onupdate=utc_now, nullable=False)
199
+
200
+ project: Mapped["ProjectOrm"] = relationship(back_populates="experiments")
201
+ dataset_version: Mapped["DatasetVersionOrm"] = relationship()
202
+ model_version: Mapped["ModelVersionOrm"] = relationship()
203
+ runs: Mapped[list["ExperimentRunOrm"]] = relationship(
204
+ back_populates="experiment", cascade="all, delete-orphan"
205
+ )
206
+ metrics: Mapped[list["MetricOrm"]] = relationship(
207
+ back_populates="experiment", cascade="all, delete-orphan"
208
+ )
209
+ artifacts: Mapped[list["ArtifactOrm"]] = relationship(
210
+ back_populates="experiment", cascade="all, delete-orphan"
211
+ )
212
+ analysis: Mapped["AnalysisRunOrm | None"] = relationship(
213
+ back_populates="experiment", uselist=False, cascade="all, delete-orphan"
214
+ )
215
+
216
+ __table_args__ = (
217
+ Index("ix_experiments_tenant_created", "tenant_id", "created_at"),
218
+ Index("ix_experiments_project_created", "project_id", "created_at"),
219
+ Index("ix_experiments_status_created", "status", "created_at"),
220
+ Index("ix_experiments_idempotency", "tenant_id", "project_id", "idempotency_key"),
221
+ )
222
+
223
+
224
+ class ExperimentRunOrm(Base):
225
+ __tablename__ = "experiment_runs"
226
+
227
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
228
+ experiment_id: Mapped[str] = mapped_column(
229
+ ForeignKey("experiments.id", ondelete="CASCADE"), nullable=False
230
+ )
231
+ run_number: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
232
+ status: Mapped[str] = mapped_column(String(32), default="QUEUED", nullable=False)
233
+ worker_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
234
+ start_time: Mapped[datetime | None] = mapped_column(nullable=True)
235
+ end_time: Mapped[datetime | None] = mapped_column(nullable=True)
236
+ duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
237
+ failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
238
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
239
+
240
+ experiment: Mapped["ExperimentOrm"] = relationship(back_populates="runs")
241
+ metrics: Mapped[list["MetricOrm"]] = relationship(
242
+ back_populates="run", cascade="all, delete-orphan"
243
+ )
244
+
245
+ __table_args__ = (Index("ix_experiment_runs_experiment", "experiment_id"),)
246
+
247
+
248
+ class MetricOrm(Base):
249
+ __tablename__ = "metrics"
250
+
251
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
252
+ experiment_id: Mapped[str] = mapped_column(
253
+ ForeignKey("experiments.id", ondelete="CASCADE"), nullable=False
254
+ )
255
+ run_id: Mapped[str] = mapped_column(
256
+ ForeignKey("experiment_runs.id", ondelete="CASCADE"), nullable=False
257
+ )
258
+ metric_name: Mapped[str] = mapped_column(String(64), nullable=False)
259
+ metric_value: Mapped[float] = mapped_column(Float, nullable=False)
260
+ step: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
261
+ split: Mapped[str] = mapped_column(
262
+ String(32), default="validation", nullable=False
263
+ ) # train, validation, test
264
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
265
+
266
+ experiment: Mapped["ExperimentOrm"] = relationship(back_populates="metrics")
267
+ run: Mapped["ExperimentRunOrm"] = relationship(back_populates="metrics")
268
+
269
+ __table_args__ = (
270
+ Index("ix_metrics_experiment_name", "experiment_id", "metric_name"),
271
+ Index("ix_metrics_run", "run_id"),
272
+ )
273
+
274
+
275
+ class AnalysisRunOrm(Base):
276
+ __tablename__ = "analysis_runs"
277
+
278
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
279
+ experiment_id: Mapped[str] = mapped_column(
280
+ ForeignKey("experiments.id", ondelete="CASCADE"), unique=True, nullable=False
281
+ )
282
+ tenant_id: Mapped[str] = mapped_column(String(36), nullable=False)
283
+ summary_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, nullable=False)
284
+ diagnostics_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, nullable=False)
285
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
286
+
287
+ experiment: Mapped["ExperimentOrm"] = relationship(back_populates="analysis")
288
+
289
+ __table_args__ = (Index("ix_analysis_tenant_created", "tenant_id", "created_at"),)
290
+
291
+
292
+ class ArtifactOrm(Base):
293
+ __tablename__ = "artifacts"
294
+
295
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
296
+ tenant_id: Mapped[str] = mapped_column(String(36), nullable=False)
297
+ experiment_id: Mapped[str] = mapped_column(
298
+ ForeignKey("experiments.id", ondelete="CASCADE"), nullable=False
299
+ )
300
+ artifact_type: Mapped[str] = mapped_column(
301
+ String(64), nullable=False
302
+ ) # model, predictions, metrics_report, etc.
303
+ storage_uri: Mapped[str] = mapped_column(String(512), nullable=False)
304
+ size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
305
+ content_hash: Mapped[str] = mapped_column(String(128), nullable=False)
306
+ metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
307
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
308
+
309
+ experiment: Mapped["ExperimentOrm"] = relationship(back_populates="artifacts")
310
+
311
+ __table_args__ = (
312
+ Index("ix_artifacts_experiment", "experiment_id"),
313
+ Index("ix_artifacts_tenant_created", "tenant_id", "created_at"),
314
+ )
315
+
316
+
317
+ class OutboxEventOrm(Base):
318
+ __tablename__ = "outbox_events"
319
+
320
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
321
+ event_type: Mapped[str] = mapped_column(String(64), nullable=False)
322
+ aggregate_type: Mapped[str] = mapped_column(String(64), nullable=False)
323
+ aggregate_id: Mapped[str] = mapped_column(String(64), nullable=False)
324
+ payload_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, nullable=False)
325
+ status: Mapped[str] = mapped_column(
326
+ String(32), default="PENDING", nullable=False
327
+ ) # PENDING, DELIVERED, FAILED
328
+ retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
329
+ scheduled_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
330
+ processed_at: Mapped[datetime | None] = mapped_column(nullable=True)
331
+ created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
332
+
333
+ __table_args__ = (Index("ix_outbox_status_scheduled", "status", "scheduled_at"),)
334
+
335
+
336
+ class AuditEventOrm(Base):
337
+ __tablename__ = "audit_events"
338
+
339
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
340
+ timestamp: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
341
+ principal_id: Mapped[str] = mapped_column(String(64), nullable=False)
342
+ tenant_id: Mapped[str] = mapped_column(String(36), nullable=False)
343
+ project_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
344
+ action: Mapped[str] = mapped_column(String(64), nullable=False)
345
+ resource_type: Mapped[str] = mapped_column(String(64), nullable=False)
346
+ resource_id: Mapped[str] = mapped_column(String(64), nullable=False)
347
+ request_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
348
+ trace_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
349
+ result: Mapped[str] = mapped_column(String(32), nullable=False) # SUCCESS, DENIED, ERROR
350
+ reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
351
+ client_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
352
+
353
+ __table_args__ = (
354
+ Index("ix_audit_tenant_timestamp", "tenant_id", "timestamp"),
355
+ Index("ix_audit_principal_timestamp", "principal_id", "timestamp"),
356
+ )