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.
- ml_mcp/__init__.py +3 -0
- ml_mcp/application/__init__.py +1 -0
- ml_mcp/application/analysis/__init__.py +15 -0
- ml_mcp/application/analysis/diagnostics.py +122 -0
- ml_mcp/application/analysis/service.py +155 -0
- ml_mcp/application/artifacts/__init__.py +5 -0
- ml_mcp/application/artifacts/service.py +149 -0
- ml_mcp/application/datasets/__init__.py +6 -0
- ml_mcp/application/datasets/service.py +178 -0
- ml_mcp/application/datasets/validator.py +156 -0
- ml_mcp/application/experiments/__init__.py +5 -0
- ml_mcp/application/experiments/service.py +254 -0
- ml_mcp/application/models/__init__.py +6 -0
- ml_mcp/application/models/catalog_seed.py +131 -0
- ml_mcp/application/models/service.py +177 -0
- ml_mcp/application/projects/__init__.py +5 -0
- ml_mcp/application/projects/service.py +93 -0
- ml_mcp/config/__init__.py +5 -0
- ml_mcp/config/settings.py +176 -0
- ml_mcp/domain/__init__.py +1 -0
- ml_mcp/domain/errors/__init__.py +39 -0
- ml_mcp/domain/errors/base.py +155 -0
- ml_mcp/domain/errors/codes.py +23 -0
- ml_mcp/domain/policies/__init__.py +16 -0
- ml_mcp/domain/policies/rbac.py +126 -0
- ml_mcp/domain/policies/tenant_isolation.py +34 -0
- ml_mcp/domain/policies/tool_policy.py +185 -0
- ml_mcp/domain/value_objects/__init__.py +41 -0
- ml_mcp/domain/value_objects/dataset.py +60 -0
- ml_mcp/domain/value_objects/experiment.py +131 -0
- ml_mcp/domain/value_objects/model.py +48 -0
- ml_mcp/infrastructure/__init__.py +1 -0
- ml_mcp/infrastructure/object_storage/__init__.py +5 -0
- ml_mcp/infrastructure/object_storage/s3.py +272 -0
- ml_mcp/infrastructure/postgres/__init__.py +14 -0
- ml_mcp/infrastructure/postgres/base.py +40 -0
- ml_mcp/infrastructure/postgres/models.py +356 -0
- ml_mcp/infrastructure/postgres/repositories/__init__.py +21 -0
- ml_mcp/infrastructure/postgres/repositories/artifacts.py +34 -0
- ml_mcp/infrastructure/postgres/repositories/audit.py +34 -0
- ml_mcp/infrastructure/postgres/repositories/datasets.py +85 -0
- ml_mcp/infrastructure/postgres/repositories/experiments.py +134 -0
- ml_mcp/infrastructure/postgres/repositories/metrics.py +61 -0
- ml_mcp/infrastructure/postgres/repositories/models.py +61 -0
- ml_mcp/infrastructure/postgres/repositories/outbox.py +47 -0
- ml_mcp/infrastructure/postgres/repositories/projects.py +83 -0
- ml_mcp/infrastructure/postgres/session.py +117 -0
- ml_mcp/infrastructure/queue/__init__.py +6 -0
- ml_mcp/infrastructure/queue/outbox_processor.py +67 -0
- ml_mcp/infrastructure/queue/task_queue.py +98 -0
- ml_mcp/infrastructure/redis/__init__.py +11 -0
- ml_mcp/infrastructure/redis/client.py +64 -0
- ml_mcp/infrastructure/redis/rate_limiter.py +129 -0
- ml_mcp/infrastructure/telemetry/__init__.py +23 -0
- ml_mcp/infrastructure/telemetry/logging.py +155 -0
- ml_mcp/infrastructure/telemetry/otel.py +141 -0
- ml_mcp/mcp/__init__.py +1 -0
- ml_mcp/mcp/prompts.py +38 -0
- ml_mcp/mcp/resources.py +35 -0
- ml_mcp/mcp/schemas/__init__.py +1 -0
- ml_mcp/mcp/schemas/tools.py +124 -0
- ml_mcp/mcp/tools/__init__.py +63 -0
- ml_mcp/mcp/tools/analysis.py +37 -0
- ml_mcp/mcp/tools/datasets.py +82 -0
- ml_mcp/mcp/tools/discovery.py +82 -0
- ml_mcp/mcp/tools/experiments.py +96 -0
- ml_mcp/mcp/tools/projects.py +39 -0
- ml_mcp/mcp/tools/results.py +59 -0
- ml_mcp/server/__init__.py +1 -0
- ml_mcp/server/app.py +488 -0
- ml_mcp/server/auth.py +146 -0
- ml_mcp/server/context.py +59 -0
- ml_mcp/server/http.py +183 -0
- ml_mcp/server/middleware.py +121 -0
- ml_mcp/server/stdio.py +42 -0
- ml_mcp/workers/__init__.py +1 -0
- ml_mcp/workers/daemon.py +130 -0
- ml_mcp/workers/metrics.py +110 -0
- ml_mcp/workers/preprocessing.py +170 -0
- ml_mcp/workers/runner.py +250 -0
- ml_mcp/workers/sandbox.py +35 -0
- ml_mcp/workers/trainers/__init__.py +52 -0
- ml_mcp/workers/trainers/base.py +52 -0
- ml_mcp/workers/trainers/catboost_model.py +85 -0
- ml_mcp/workers/trainers/lightgbm_model.py +81 -0
- ml_mcp/workers/trainers/sklearn_models.py +266 -0
- ml_mcp/workers/trainers/xgboost_model.py +82 -0
- quicklab_mcp-0.1.0.dist-info/METADATA +540 -0
- quicklab_mcp-0.1.0.dist-info/RECORD +92 -0
- quicklab_mcp-0.1.0.dist-info/WHEEL +5 -0
- quicklab_mcp-0.1.0.dist-info/entry_points.txt +4 -0
- quicklab_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""PostgreSQL Repositories package."""
|
|
2
|
+
|
|
3
|
+
from ml_mcp.infrastructure.postgres.repositories.artifacts import ArtifactRepository
|
|
4
|
+
from ml_mcp.infrastructure.postgres.repositories.audit import AuditRepository
|
|
5
|
+
from ml_mcp.infrastructure.postgres.repositories.datasets import DatasetRepository
|
|
6
|
+
from ml_mcp.infrastructure.postgres.repositories.experiments import ExperimentRepository
|
|
7
|
+
from ml_mcp.infrastructure.postgres.repositories.metrics import MetricRepository
|
|
8
|
+
from ml_mcp.infrastructure.postgres.repositories.models import ModelRepository
|
|
9
|
+
from ml_mcp.infrastructure.postgres.repositories.outbox import OutboxRepository
|
|
10
|
+
from ml_mcp.infrastructure.postgres.repositories.projects import ProjectRepository
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ModelRepository",
|
|
14
|
+
"DatasetRepository",
|
|
15
|
+
"ExperimentRepository",
|
|
16
|
+
"MetricRepository",
|
|
17
|
+
"ArtifactRepository",
|
|
18
|
+
"OutboxRepository",
|
|
19
|
+
"AuditRepository",
|
|
20
|
+
"ProjectRepository",
|
|
21
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Repository for experiment artifact metadata."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from ml_mcp.infrastructure.postgres.models import ArtifactOrm
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ArtifactRepository:
|
|
10
|
+
"""Tenant-scoped data access for persisted analytical and model artifacts."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
13
|
+
self.session = session
|
|
14
|
+
|
|
15
|
+
async def record_artifact(self, artifact: ArtifactOrm) -> ArtifactOrm:
|
|
16
|
+
self.session.add(artifact)
|
|
17
|
+
await self.session.flush()
|
|
18
|
+
return artifact
|
|
19
|
+
|
|
20
|
+
async def list_artifacts(self, tenant_id: str, experiment_id: str) -> list[ArtifactOrm]:
|
|
21
|
+
stmt = (
|
|
22
|
+
select(ArtifactOrm)
|
|
23
|
+
.where(ArtifactOrm.tenant_id == tenant_id, ArtifactOrm.experiment_id == experiment_id)
|
|
24
|
+
.order_by(ArtifactOrm.created_at.desc())
|
|
25
|
+
)
|
|
26
|
+
result = await self.session.execute(stmt)
|
|
27
|
+
return list(result.scalars().all())
|
|
28
|
+
|
|
29
|
+
async def get_artifact(self, tenant_id: str, artifact_id: str) -> ArtifactOrm | None:
|
|
30
|
+
stmt = select(ArtifactOrm).where(
|
|
31
|
+
ArtifactOrm.tenant_id == tenant_id, ArtifactOrm.id == artifact_id
|
|
32
|
+
)
|
|
33
|
+
result = await self.session.execute(stmt)
|
|
34
|
+
return result.scalar_one_or_none()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Repository for immutable audit log records."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from ml_mcp.infrastructure.postgres.models import AuditEventOrm
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuditRepository:
|
|
10
|
+
"""Append-only audit trail repository."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
13
|
+
self.session = session
|
|
14
|
+
|
|
15
|
+
async def record_event(self, event: AuditEventOrm) -> AuditEventOrm:
|
|
16
|
+
self.session.add(event)
|
|
17
|
+
await self.session.flush()
|
|
18
|
+
return event
|
|
19
|
+
|
|
20
|
+
async def list_events(
|
|
21
|
+
self,
|
|
22
|
+
tenant_id: str,
|
|
23
|
+
limit: int = 100,
|
|
24
|
+
offset: int = 0,
|
|
25
|
+
) -> list[AuditEventOrm]:
|
|
26
|
+
stmt = (
|
|
27
|
+
select(AuditEventOrm)
|
|
28
|
+
.where(AuditEventOrm.tenant_id == tenant_id)
|
|
29
|
+
.order_by(AuditEventOrm.timestamp.desc())
|
|
30
|
+
.limit(limit)
|
|
31
|
+
.offset(offset)
|
|
32
|
+
)
|
|
33
|
+
result = await self.session.execute(stmt)
|
|
34
|
+
return list(result.scalars().all())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Repository for dataset and dataset version persistence."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
from sqlalchemy.orm import selectinload
|
|
6
|
+
|
|
7
|
+
from ml_mcp.infrastructure.postgres.models import DatasetOrm, DatasetVersionOrm
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DatasetRepository:
|
|
11
|
+
"""Tenant-scoped data access for registered datasets and versions."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
14
|
+
self.session = session
|
|
15
|
+
|
|
16
|
+
async def create_dataset(self, dataset: DatasetOrm) -> DatasetOrm:
|
|
17
|
+
self.session.add(dataset)
|
|
18
|
+
await self.session.flush()
|
|
19
|
+
return dataset
|
|
20
|
+
|
|
21
|
+
async def get_dataset(self, tenant_id: str, dataset_id: str) -> DatasetOrm | None:
|
|
22
|
+
stmt = (
|
|
23
|
+
select(DatasetOrm)
|
|
24
|
+
.where(DatasetOrm.tenant_id == tenant_id, DatasetOrm.id == dataset_id)
|
|
25
|
+
.options(selectinload(DatasetOrm.versions))
|
|
26
|
+
)
|
|
27
|
+
result = await self.session.execute(stmt)
|
|
28
|
+
return result.scalar_one_or_none()
|
|
29
|
+
|
|
30
|
+
async def list_datasets(
|
|
31
|
+
self, tenant_id: str, project_id: str | None = None
|
|
32
|
+
) -> list[DatasetOrm]:
|
|
33
|
+
stmt = select(DatasetOrm).where(DatasetOrm.tenant_id == tenant_id)
|
|
34
|
+
if project_id:
|
|
35
|
+
stmt = stmt.where(DatasetOrm.project_id == project_id)
|
|
36
|
+
stmt = stmt.options(selectinload(DatasetOrm.versions)).order_by(
|
|
37
|
+
DatasetOrm.created_at.desc()
|
|
38
|
+
)
|
|
39
|
+
result = await self.session.execute(stmt)
|
|
40
|
+
return list(result.scalars().all())
|
|
41
|
+
|
|
42
|
+
async def create_version(self, version: DatasetVersionOrm) -> DatasetVersionOrm:
|
|
43
|
+
self.session.add(version)
|
|
44
|
+
await self.session.flush()
|
|
45
|
+
return version
|
|
46
|
+
|
|
47
|
+
async def get_version(
|
|
48
|
+
self, tenant_id: str, dataset_id: str, version: str
|
|
49
|
+
) -> DatasetVersionOrm | None:
|
|
50
|
+
stmt = (
|
|
51
|
+
select(DatasetVersionOrm)
|
|
52
|
+
.join(DatasetOrm, DatasetVersionOrm.dataset_id == DatasetOrm.id)
|
|
53
|
+
.where(
|
|
54
|
+
DatasetOrm.tenant_id == tenant_id,
|
|
55
|
+
DatasetVersionOrm.dataset_id == dataset_id,
|
|
56
|
+
DatasetVersionOrm.version == version,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
result = await self.session.execute(stmt)
|
|
60
|
+
return result.scalar_one_or_none()
|
|
61
|
+
|
|
62
|
+
async def get_version_by_id(self, tenant_id: str, version_id: str) -> DatasetVersionOrm | None:
|
|
63
|
+
stmt = (
|
|
64
|
+
select(DatasetVersionOrm)
|
|
65
|
+
.join(DatasetOrm, DatasetVersionOrm.dataset_id == DatasetOrm.id)
|
|
66
|
+
.where(
|
|
67
|
+
DatasetOrm.tenant_id == tenant_id,
|
|
68
|
+
DatasetVersionOrm.id == version_id,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
result = await self.session.execute(stmt)
|
|
72
|
+
return result.scalar_one_or_none()
|
|
73
|
+
|
|
74
|
+
async def list_versions(self, tenant_id: str, dataset_id: str) -> list[DatasetVersionOrm]:
|
|
75
|
+
stmt = (
|
|
76
|
+
select(DatasetVersionOrm)
|
|
77
|
+
.join(DatasetOrm, DatasetVersionOrm.dataset_id == DatasetOrm.id)
|
|
78
|
+
.where(
|
|
79
|
+
DatasetOrm.tenant_id == tenant_id,
|
|
80
|
+
DatasetVersionOrm.dataset_id == dataset_id,
|
|
81
|
+
)
|
|
82
|
+
.order_by(DatasetVersionOrm.created_at.desc())
|
|
83
|
+
)
|
|
84
|
+
result = await self.session.execute(stmt)
|
|
85
|
+
return list(result.scalars().all())
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Repository for experiments, runs, and transactional outbox emission."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select, update
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
from sqlalchemy.orm import selectinload
|
|
8
|
+
|
|
9
|
+
from ml_mcp.infrastructure.postgres.base import utc_now
|
|
10
|
+
from ml_mcp.infrastructure.postgres.models import (
|
|
11
|
+
ExperimentOrm,
|
|
12
|
+
ExperimentRunOrm,
|
|
13
|
+
ModelVersionOrm,
|
|
14
|
+
OutboxEventOrm,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExperimentRepository:
|
|
19
|
+
"""Tenant-scoped data access for experiments and execution runs."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
22
|
+
self.session = session
|
|
23
|
+
|
|
24
|
+
async def create_experiment(
|
|
25
|
+
self,
|
|
26
|
+
experiment: ExperimentOrm,
|
|
27
|
+
initial_outbox_event: OutboxEventOrm | None = None,
|
|
28
|
+
) -> ExperimentOrm:
|
|
29
|
+
"""Create experiment and optional transactional outbox event atomically."""
|
|
30
|
+
self.session.add(experiment)
|
|
31
|
+
if initial_outbox_event:
|
|
32
|
+
self.session.add(initial_outbox_event)
|
|
33
|
+
await self.session.flush()
|
|
34
|
+
return experiment
|
|
35
|
+
|
|
36
|
+
async def get_by_idempotency_key(
|
|
37
|
+
self,
|
|
38
|
+
tenant_id: str,
|
|
39
|
+
project_id: str,
|
|
40
|
+
idempotency_key: str,
|
|
41
|
+
) -> ExperimentOrm | None:
|
|
42
|
+
stmt = (
|
|
43
|
+
select(ExperimentOrm)
|
|
44
|
+
.where(
|
|
45
|
+
ExperimentOrm.tenant_id == tenant_id,
|
|
46
|
+
ExperimentOrm.project_id == project_id,
|
|
47
|
+
ExperimentOrm.idempotency_key == idempotency_key,
|
|
48
|
+
)
|
|
49
|
+
.options(selectinload(ExperimentOrm.runs))
|
|
50
|
+
)
|
|
51
|
+
result = await self.session.execute(stmt)
|
|
52
|
+
return result.scalar_one_or_none()
|
|
53
|
+
|
|
54
|
+
async def get_experiment(self, tenant_id: str, experiment_id: str) -> ExperimentOrm | None:
|
|
55
|
+
stmt = (
|
|
56
|
+
select(ExperimentOrm)
|
|
57
|
+
.where(ExperimentOrm.tenant_id == tenant_id, ExperimentOrm.id == experiment_id)
|
|
58
|
+
.options(
|
|
59
|
+
selectinload(ExperimentOrm.runs),
|
|
60
|
+
selectinload(ExperimentOrm.metrics),
|
|
61
|
+
selectinload(ExperimentOrm.artifacts),
|
|
62
|
+
selectinload(ExperimentOrm.analysis),
|
|
63
|
+
selectinload(ExperimentOrm.model_version).selectinload(ModelVersionOrm.model),
|
|
64
|
+
selectinload(ExperimentOrm.dataset_version),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
result = await self.session.execute(stmt)
|
|
68
|
+
return result.scalar_one_or_none()
|
|
69
|
+
|
|
70
|
+
async def list_experiments(
|
|
71
|
+
self,
|
|
72
|
+
tenant_id: str,
|
|
73
|
+
project_id: str | None = None,
|
|
74
|
+
status: str | None = None,
|
|
75
|
+
limit: int = 50,
|
|
76
|
+
offset: int = 0,
|
|
77
|
+
) -> list[ExperimentOrm]:
|
|
78
|
+
stmt = select(ExperimentOrm).where(ExperimentOrm.tenant_id == tenant_id)
|
|
79
|
+
if project_id:
|
|
80
|
+
stmt = stmt.where(ExperimentOrm.project_id == project_id)
|
|
81
|
+
if status:
|
|
82
|
+
stmt = stmt.where(ExperimentOrm.status == status)
|
|
83
|
+
stmt = (
|
|
84
|
+
stmt.options(selectinload(ExperimentOrm.runs))
|
|
85
|
+
.order_by(ExperimentOrm.created_at.desc())
|
|
86
|
+
.limit(limit)
|
|
87
|
+
.offset(offset)
|
|
88
|
+
)
|
|
89
|
+
result = await self.session.execute(stmt)
|
|
90
|
+
return list(result.scalars().all())
|
|
91
|
+
|
|
92
|
+
async def update_status(self, tenant_id: str, experiment_id: str, new_status: str) -> bool:
|
|
93
|
+
stmt = (
|
|
94
|
+
update(ExperimentOrm)
|
|
95
|
+
.where(ExperimentOrm.tenant_id == tenant_id, ExperimentOrm.id == experiment_id)
|
|
96
|
+
.values(status=new_status, updated_at=utc_now())
|
|
97
|
+
)
|
|
98
|
+
result = await self.session.execute(stmt)
|
|
99
|
+
return result.rowcount > 0
|
|
100
|
+
|
|
101
|
+
async def create_run(self, run: ExperimentRunOrm) -> ExperimentRunOrm:
|
|
102
|
+
self.session.add(run)
|
|
103
|
+
await self.session.flush()
|
|
104
|
+
return run
|
|
105
|
+
|
|
106
|
+
async def update_run(
|
|
107
|
+
self,
|
|
108
|
+
run_id: str,
|
|
109
|
+
status: str,
|
|
110
|
+
duration_seconds: float | None = None,
|
|
111
|
+
failure_reason: str | None = None,
|
|
112
|
+
end_time: bool = False,
|
|
113
|
+
) -> bool:
|
|
114
|
+
values: dict[str, Any] = {"status": status}
|
|
115
|
+
if duration_seconds is not None:
|
|
116
|
+
values["duration_seconds"] = duration_seconds
|
|
117
|
+
if failure_reason is not None:
|
|
118
|
+
values["failure_reason"] = failure_reason
|
|
119
|
+
if end_time:
|
|
120
|
+
values["end_time"] = utc_now()
|
|
121
|
+
|
|
122
|
+
stmt = update(ExperimentRunOrm).where(ExperimentRunOrm.id == run_id).values(**values)
|
|
123
|
+
result = await self.session.execute(stmt)
|
|
124
|
+
return result.rowcount > 0
|
|
125
|
+
|
|
126
|
+
async def get_latest_run(self, experiment_id: str) -> ExperimentRunOrm | None:
|
|
127
|
+
stmt = (
|
|
128
|
+
select(ExperimentRunOrm)
|
|
129
|
+
.where(ExperimentRunOrm.experiment_id == experiment_id)
|
|
130
|
+
.order_by(ExperimentRunOrm.run_number.desc())
|
|
131
|
+
.limit(1)
|
|
132
|
+
)
|
|
133
|
+
result = await self.session.execute(stmt)
|
|
134
|
+
return result.scalar_one_or_none()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Repository for evaluation metrics recording and cross-experiment comparison."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from ml_mcp.infrastructure.postgres.models import ExperimentOrm, MetricOrm
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MetricRepository:
|
|
10
|
+
"""Tenant-scoped data access for experiment evaluation metrics."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
13
|
+
self.session = session
|
|
14
|
+
|
|
15
|
+
async def record_metrics(self, metrics: list[MetricOrm]) -> list[MetricOrm]:
|
|
16
|
+
self.session.add_all(metrics)
|
|
17
|
+
await self.session.flush()
|
|
18
|
+
return metrics
|
|
19
|
+
|
|
20
|
+
async def get_metrics(
|
|
21
|
+
self,
|
|
22
|
+
tenant_id: str,
|
|
23
|
+
experiment_id: str,
|
|
24
|
+
split: str | None = None,
|
|
25
|
+
) -> list[MetricOrm]:
|
|
26
|
+
stmt = (
|
|
27
|
+
select(MetricOrm)
|
|
28
|
+
.join(ExperimentOrm, MetricOrm.experiment_id == ExperimentOrm.id)
|
|
29
|
+
.where(ExperimentOrm.tenant_id == tenant_id, MetricOrm.experiment_id == experiment_id)
|
|
30
|
+
)
|
|
31
|
+
if split:
|
|
32
|
+
stmt = stmt.where(MetricOrm.split == split)
|
|
33
|
+
stmt = stmt.order_by(MetricOrm.metric_name)
|
|
34
|
+
result = await self.session.execute(stmt)
|
|
35
|
+
return list(result.scalars().all())
|
|
36
|
+
|
|
37
|
+
async def compare_metrics(
|
|
38
|
+
self,
|
|
39
|
+
tenant_id: str,
|
|
40
|
+
experiment_ids: list[str],
|
|
41
|
+
) -> dict[str, dict[str, float]]:
|
|
42
|
+
"""Return {experiment_id: {metric_name: metric_value}} across authorized experiments."""
|
|
43
|
+
if not experiment_ids:
|
|
44
|
+
return {}
|
|
45
|
+
|
|
46
|
+
stmt = (
|
|
47
|
+
select(MetricOrm)
|
|
48
|
+
.join(ExperimentOrm, MetricOrm.experiment_id == ExperimentOrm.id)
|
|
49
|
+
.where(
|
|
50
|
+
ExperimentOrm.tenant_id == tenant_id,
|
|
51
|
+
MetricOrm.experiment_id.in_(experiment_ids),
|
|
52
|
+
MetricOrm.split == "validation",
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
result = await self.session.execute(stmt)
|
|
56
|
+
rows = result.scalars().all()
|
|
57
|
+
|
|
58
|
+
comparison: dict[str, dict[str, float]] = {eid: {} for eid in experiment_ids}
|
|
59
|
+
for row in rows:
|
|
60
|
+
comparison[row.experiment_id][row.metric_name] = row.metric_value
|
|
61
|
+
return comparison
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Repository for registered models and model versions."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
from sqlalchemy.orm import selectinload
|
|
6
|
+
|
|
7
|
+
from ml_mcp.infrastructure.postgres.models import ModelOrm, ModelVersionOrm
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ModelRepository:
|
|
11
|
+
"""Data access operations for the approved model catalog."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
14
|
+
self.session = session
|
|
15
|
+
|
|
16
|
+
async def get_by_id(self, model_id: str) -> ModelOrm | None:
|
|
17
|
+
stmt = (
|
|
18
|
+
select(ModelOrm).where(ModelOrm.id == model_id).options(selectinload(ModelOrm.versions))
|
|
19
|
+
)
|
|
20
|
+
result = await self.session.execute(stmt)
|
|
21
|
+
return result.scalar_one_or_none()
|
|
22
|
+
|
|
23
|
+
async def list_models(self) -> list[ModelOrm]:
|
|
24
|
+
stmt = select(ModelOrm).options(selectinload(ModelOrm.versions)).order_by(ModelOrm.id)
|
|
25
|
+
result = await self.session.execute(stmt)
|
|
26
|
+
return list(result.scalars().all())
|
|
27
|
+
|
|
28
|
+
async def get_version(self, model_id: str, version: str) -> ModelVersionOrm | None:
|
|
29
|
+
stmt = select(ModelVersionOrm).where(
|
|
30
|
+
ModelVersionOrm.model_id == model_id, ModelVersionOrm.version == version
|
|
31
|
+
)
|
|
32
|
+
result = await self.session.execute(stmt)
|
|
33
|
+
return result.scalar_one_or_none()
|
|
34
|
+
|
|
35
|
+
async def get_version_by_id(self, version_id: str) -> ModelVersionOrm | None:
|
|
36
|
+
stmt = (
|
|
37
|
+
select(ModelVersionOrm)
|
|
38
|
+
.where(ModelVersionOrm.id == version_id)
|
|
39
|
+
.options(selectinload(ModelVersionOrm.model))
|
|
40
|
+
)
|
|
41
|
+
result = await self.session.execute(stmt)
|
|
42
|
+
return result.scalar_one_or_none()
|
|
43
|
+
|
|
44
|
+
async def list_versions(self, model_id: str) -> list[ModelVersionOrm]:
|
|
45
|
+
stmt = (
|
|
46
|
+
select(ModelVersionOrm)
|
|
47
|
+
.where(ModelVersionOrm.model_id == model_id)
|
|
48
|
+
.order_by(ModelVersionOrm.created_at.desc())
|
|
49
|
+
)
|
|
50
|
+
result = await self.session.execute(stmt)
|
|
51
|
+
return list(result.scalars().all())
|
|
52
|
+
|
|
53
|
+
async def create_model(self, model: ModelOrm) -> ModelOrm:
|
|
54
|
+
self.session.add(model)
|
|
55
|
+
await self.session.flush()
|
|
56
|
+
return model
|
|
57
|
+
|
|
58
|
+
async def create_version(self, version: ModelVersionOrm) -> ModelVersionOrm:
|
|
59
|
+
self.session.add(version)
|
|
60
|
+
await self.session.flush()
|
|
61
|
+
return version
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Repository for Outbox event lifecycle management."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select, update
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from ml_mcp.infrastructure.postgres.base import utc_now
|
|
7
|
+
from ml_mcp.infrastructure.postgres.models import OutboxEventOrm
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OutboxRepository:
|
|
11
|
+
"""Operations for polling and updating transactional outbox events."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
14
|
+
self.session = session
|
|
15
|
+
|
|
16
|
+
async def fetch_pending(self, limit: int = 50) -> list[OutboxEventOrm]:
|
|
17
|
+
"""Fetch pending outbox events scheduled for delivery."""
|
|
18
|
+
stmt = (
|
|
19
|
+
select(OutboxEventOrm)
|
|
20
|
+
.where(
|
|
21
|
+
OutboxEventOrm.status == "PENDING",
|
|
22
|
+
OutboxEventOrm.scheduled_at <= utc_now(),
|
|
23
|
+
)
|
|
24
|
+
.order_by(OutboxEventOrm.scheduled_at.asc())
|
|
25
|
+
.limit(limit)
|
|
26
|
+
)
|
|
27
|
+
result = await self.session.execute(stmt)
|
|
28
|
+
return list(result.scalars().all())
|
|
29
|
+
|
|
30
|
+
async def mark_delivered(self, event_id: str) -> bool:
|
|
31
|
+
stmt = (
|
|
32
|
+
update(OutboxEventOrm)
|
|
33
|
+
.where(OutboxEventOrm.id == event_id)
|
|
34
|
+
.values(status="DELIVERED", processed_at=utc_now())
|
|
35
|
+
)
|
|
36
|
+
result = await self.session.execute(stmt)
|
|
37
|
+
return result.rowcount > 0
|
|
38
|
+
|
|
39
|
+
async def mark_failed(self, event_id: str, retry_count: int, max_retries: int = 5) -> bool:
|
|
40
|
+
new_status = "FAILED" if retry_count >= max_retries else "PENDING"
|
|
41
|
+
stmt = (
|
|
42
|
+
update(OutboxEventOrm)
|
|
43
|
+
.where(OutboxEventOrm.id == event_id)
|
|
44
|
+
.values(status=new_status, retry_count=retry_count)
|
|
45
|
+
)
|
|
46
|
+
result = await self.session.execute(stmt)
|
|
47
|
+
return result.rowcount > 0
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Repository for project control plane persistence."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from ml_mcp.infrastructure.postgres.models import ProjectOrm, TenantOrm
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProjectRepository:
|
|
10
|
+
"""Tenant-scoped data access for projects."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
13
|
+
self.session = session
|
|
14
|
+
|
|
15
|
+
async def create_project(self, project: ProjectOrm) -> ProjectOrm:
|
|
16
|
+
self.session.add(project)
|
|
17
|
+
await self.session.flush()
|
|
18
|
+
return project
|
|
19
|
+
|
|
20
|
+
async def get_project(self, tenant_id: str, project_id: str) -> ProjectOrm | None:
|
|
21
|
+
stmt = select(ProjectOrm).where(
|
|
22
|
+
ProjectOrm.tenant_id == tenant_id,
|
|
23
|
+
ProjectOrm.id == project_id,
|
|
24
|
+
)
|
|
25
|
+
result = await self.session.execute(stmt)
|
|
26
|
+
return result.scalar_one_or_none()
|
|
27
|
+
|
|
28
|
+
async def get_project_by_name(self, tenant_id: str, name: str) -> ProjectOrm | None:
|
|
29
|
+
stmt = select(ProjectOrm).where(
|
|
30
|
+
ProjectOrm.tenant_id == tenant_id,
|
|
31
|
+
ProjectOrm.name == name,
|
|
32
|
+
)
|
|
33
|
+
result = await self.session.execute(stmt)
|
|
34
|
+
return result.scalar_one_or_none()
|
|
35
|
+
|
|
36
|
+
async def list_projects(
|
|
37
|
+
self, tenant_id: str, limit: int = 50, offset: int = 0
|
|
38
|
+
) -> list[ProjectOrm]:
|
|
39
|
+
stmt = (
|
|
40
|
+
select(ProjectOrm)
|
|
41
|
+
.where(ProjectOrm.tenant_id == tenant_id)
|
|
42
|
+
.order_by(ProjectOrm.created_at.desc())
|
|
43
|
+
.limit(limit)
|
|
44
|
+
.offset(offset)
|
|
45
|
+
)
|
|
46
|
+
result = await self.session.execute(stmt)
|
|
47
|
+
return list(result.scalars().all())
|
|
48
|
+
|
|
49
|
+
async def get_or_create_default_project(
|
|
50
|
+
self, tenant_id: str = "default-tenant"
|
|
51
|
+
) -> ProjectOrm:
|
|
52
|
+
"""Ensure tenant and default project exist, returning the project."""
|
|
53
|
+
# 1. Ensure tenant exists
|
|
54
|
+
tenant_stmt = select(TenantOrm).where(TenantOrm.id == tenant_id)
|
|
55
|
+
tenant_res = await self.session.execute(tenant_stmt)
|
|
56
|
+
tenant = tenant_res.scalar_one_or_none()
|
|
57
|
+
if not tenant:
|
|
58
|
+
tenant = TenantOrm(
|
|
59
|
+
id=tenant_id,
|
|
60
|
+
name="Default Tenant" if tenant_id == "default-tenant" else tenant_id,
|
|
61
|
+
)
|
|
62
|
+
self.session.add(tenant)
|
|
63
|
+
await self.session.flush()
|
|
64
|
+
|
|
65
|
+
# 2. Ensure default-project exists
|
|
66
|
+
proj_id = "default-project" if tenant_id == "default-tenant" else f"{tenant_id}-default-project"
|
|
67
|
+
proj_stmt = select(ProjectOrm).where(
|
|
68
|
+
ProjectOrm.tenant_id == tenant_id,
|
|
69
|
+
(ProjectOrm.id == proj_id) | (ProjectOrm.name == "Default Project"),
|
|
70
|
+
)
|
|
71
|
+
proj_res = await self.session.execute(proj_stmt)
|
|
72
|
+
project = proj_res.scalar_one_or_none()
|
|
73
|
+
if not project:
|
|
74
|
+
project = ProjectOrm(
|
|
75
|
+
id=proj_id,
|
|
76
|
+
tenant_id=tenant_id,
|
|
77
|
+
name="Default Project",
|
|
78
|
+
description="Default ModelLab experimentation project",
|
|
79
|
+
)
|
|
80
|
+
self.session.add(project)
|
|
81
|
+
await self.session.flush()
|
|
82
|
+
|
|
83
|
+
return project
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Async database engine, session factory, and lifecycle management."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import text
|
|
9
|
+
from sqlalchemy.ext.asyncio import (
|
|
10
|
+
AsyncEngine,
|
|
11
|
+
AsyncSession,
|
|
12
|
+
async_sessionmaker,
|
|
13
|
+
create_async_engine,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from ml_mcp.config import Settings, get_settings
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DatabaseManager:
|
|
22
|
+
"""Manages async PostgreSQL connection pool and session factories."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
25
|
+
self.settings = settings or get_settings()
|
|
26
|
+
self._engine: AsyncEngine | None = None
|
|
27
|
+
self._sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
|
28
|
+
|
|
29
|
+
def initialize(self, custom_url: str | None = None) -> None:
|
|
30
|
+
"""Initialize the async engine and sessionmaker."""
|
|
31
|
+
db_url = custom_url or self.settings.database.url
|
|
32
|
+
|
|
33
|
+
engine_kwargs: dict[str, Any] = {
|
|
34
|
+
"echo": self.settings.database.echo,
|
|
35
|
+
"pool_pre_ping": True,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# SQLite requires StaticPool for in-memory and does not support pool_size or max_overflow
|
|
39
|
+
if db_url.startswith("sqlite"):
|
|
40
|
+
from sqlalchemy.pool import StaticPool
|
|
41
|
+
|
|
42
|
+
engine_kwargs["poolclass"] = StaticPool
|
|
43
|
+
engine_kwargs["connect_args"] = {"check_same_thread": False}
|
|
44
|
+
else:
|
|
45
|
+
engine_kwargs.update(
|
|
46
|
+
{
|
|
47
|
+
"pool_size": self.settings.database.pool_size,
|
|
48
|
+
"max_overflow": self.settings.database.max_overflow,
|
|
49
|
+
"pool_timeout": self.settings.database.pool_timeout,
|
|
50
|
+
"pool_recycle": self.settings.database.pool_recycle,
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
self._engine = create_async_engine(db_url, **engine_kwargs)
|
|
55
|
+
self._sessionmaker = async_sessionmaker(
|
|
56
|
+
bind=self._engine,
|
|
57
|
+
class_=AsyncSession,
|
|
58
|
+
expire_on_commit=False,
|
|
59
|
+
autoflush=False,
|
|
60
|
+
)
|
|
61
|
+
logger.info(
|
|
62
|
+
"Database engine initialized for %s", db_url.split("@")[-1] if "@" in db_url else db_url
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def engine(self) -> AsyncEngine:
|
|
67
|
+
if self._engine is None:
|
|
68
|
+
self.initialize()
|
|
69
|
+
assert self._engine is not None
|
|
70
|
+
return self._engine
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def sessionmaker(self) -> async_sessionmaker[AsyncSession]:
|
|
74
|
+
if self._sessionmaker is None:
|
|
75
|
+
self.initialize()
|
|
76
|
+
assert self._sessionmaker is not None
|
|
77
|
+
return self._sessionmaker
|
|
78
|
+
|
|
79
|
+
@asynccontextmanager
|
|
80
|
+
async def session(self) -> AsyncGenerator[AsyncSession]:
|
|
81
|
+
"""Provide a transactional async session scope."""
|
|
82
|
+
async with self.sessionmaker() as sess:
|
|
83
|
+
try:
|
|
84
|
+
yield sess
|
|
85
|
+
await sess.commit()
|
|
86
|
+
except Exception:
|
|
87
|
+
await sess.rollback()
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
async def check_health(self) -> bool:
|
|
91
|
+
"""Perform a quick healthcheck query (SELECT 1)."""
|
|
92
|
+
try:
|
|
93
|
+
async with self.session() as sess:
|
|
94
|
+
result = await sess.execute(text("SELECT 1"))
|
|
95
|
+
return result.scalar() == 1
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
logger.error("Database healthcheck failed: %s", exc)
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
async def close(self) -> None:
|
|
101
|
+
"""Dispose the underlying connection pool."""
|
|
102
|
+
if self._engine is not None:
|
|
103
|
+
await self._engine.dispose()
|
|
104
|
+
self._engine = None
|
|
105
|
+
self._sessionmaker = None
|
|
106
|
+
logger.info("Database connection pool disposed")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_db_manager: DatabaseManager | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_db_manager() -> DatabaseManager:
|
|
113
|
+
"""Return singleton DatabaseManager instance."""
|
|
114
|
+
global _db_manager
|
|
115
|
+
if _db_manager is None:
|
|
116
|
+
_db_manager = DatabaseManager()
|
|
117
|
+
return _db_manager
|