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
ml_mcp/server/http.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""HTTP transport application for ModelLab FastMCP Server.
|
|
2
|
+
|
|
3
|
+
Exposes Streamable HTTP endpoint (/mcp), health probes (/health/live, /health/ready),
|
|
4
|
+
and enforces OAuth 2.1 / OIDC Bearer token authentication middleware.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from starlette.applications import Starlette
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import JSONResponse
|
|
12
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
13
|
+
|
|
14
|
+
from ml_mcp.config import get_settings
|
|
15
|
+
from ml_mcp.infrastructure.object_storage.s3 import get_storage_service
|
|
16
|
+
from ml_mcp.infrastructure.postgres.session import get_db_manager
|
|
17
|
+
from ml_mcp.infrastructure.redis.client import get_redis_manager
|
|
18
|
+
from ml_mcp.server.app import create_server
|
|
19
|
+
from ml_mcp.server.auth import get_token_validator
|
|
20
|
+
from ml_mcp.server.context import set_current_principal
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BearerAuthMiddleware:
|
|
26
|
+
"""Pure ASGI middleware enforcing Bearer token authentication on protected MCP endpoints."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
29
|
+
self.app = app
|
|
30
|
+
|
|
31
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
32
|
+
if scope["type"] != "http":
|
|
33
|
+
await self.app(scope, receive, send)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
path: str = scope.get("path", "")
|
|
37
|
+
# Health probes bypass token authentication
|
|
38
|
+
if path.startswith("/health"):
|
|
39
|
+
await self.app(scope, receive, send)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
settings = get_settings()
|
|
43
|
+
if not settings.auth.enabled:
|
|
44
|
+
await self.app(scope, receive, send)
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
# Extract Authorization header
|
|
48
|
+
headers = dict(scope.get("headers", []))
|
|
49
|
+
auth_header_bytes = headers.get(b"authorization", b"")
|
|
50
|
+
auth_header = auth_header_bytes.decode("utf-8") if auth_header_bytes else ""
|
|
51
|
+
|
|
52
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
53
|
+
response = JSONResponse(
|
|
54
|
+
{
|
|
55
|
+
"error": "Unauthorized",
|
|
56
|
+
"code": "AUTHENTICATION_REQUIRED",
|
|
57
|
+
"detail": "Missing Bearer token in Authorization header",
|
|
58
|
+
},
|
|
59
|
+
status_code=401,
|
|
60
|
+
)
|
|
61
|
+
await response(scope, receive, send)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
token = auth_header.split(" ", 1)[1].strip()
|
|
65
|
+
try:
|
|
66
|
+
validator = get_token_validator()
|
|
67
|
+
principal = validator.validate_token(token)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
logger.warning("Authentication failure: %s", exc)
|
|
70
|
+
response = JSONResponse(
|
|
71
|
+
{
|
|
72
|
+
"error": "Unauthorized",
|
|
73
|
+
"code": "INVALID_TOKEN",
|
|
74
|
+
"detail": str(exc),
|
|
75
|
+
},
|
|
76
|
+
status_code=401,
|
|
77
|
+
)
|
|
78
|
+
await response(scope, receive, send)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
# Bind authenticated principal to context variable for downstream handlers
|
|
82
|
+
set_current_principal(principal)
|
|
83
|
+
try:
|
|
84
|
+
await self.app(scope, receive, send)
|
|
85
|
+
finally:
|
|
86
|
+
set_current_principal(None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def health_live(request: Request) -> JSONResponse:
|
|
90
|
+
"""Liveness probe: verifies process is alive and responsive."""
|
|
91
|
+
return JSONResponse({"status": "alive", "service": "modellab-mcp-api"})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def health_ready(request: Request) -> JSONResponse:
|
|
95
|
+
"""Readiness probe: verifies operational connectivity to Postgres, Redis, and Object Store."""
|
|
96
|
+
checks: dict[str, str] = {}
|
|
97
|
+
all_healthy = True
|
|
98
|
+
|
|
99
|
+
# 1. Database Check
|
|
100
|
+
try:
|
|
101
|
+
from sqlalchemy import text
|
|
102
|
+
|
|
103
|
+
async with get_db_manager().session() as sess:
|
|
104
|
+
await sess.execute(text("SELECT 1"))
|
|
105
|
+
checks["database"] = "healthy"
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
checks["database"] = f"unhealthy: {exc}"
|
|
108
|
+
all_healthy = False
|
|
109
|
+
|
|
110
|
+
# 2. Redis Check
|
|
111
|
+
try:
|
|
112
|
+
redis_mgr = get_redis_manager()
|
|
113
|
+
healthy = await redis_mgr.check_health()
|
|
114
|
+
checks["redis"] = "healthy" if healthy else "degraded"
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
checks["redis"] = f"degraded: {exc}"
|
|
117
|
+
# Redis failure can fall back to in-memory, but flag status
|
|
118
|
+
|
|
119
|
+
# 3. Object Store Check
|
|
120
|
+
try:
|
|
121
|
+
storage = get_storage_service()
|
|
122
|
+
# Verify storage client initialized
|
|
123
|
+
if storage.client is not None:
|
|
124
|
+
checks["object_store"] = "healthy"
|
|
125
|
+
else:
|
|
126
|
+
checks["object_store"] = "uninitialized"
|
|
127
|
+
all_healthy = False
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
checks["object_store"] = f"unhealthy: {exc}"
|
|
130
|
+
all_healthy = False
|
|
131
|
+
|
|
132
|
+
status_code = 200 if all_healthy else 503
|
|
133
|
+
return JSONResponse(
|
|
134
|
+
{
|
|
135
|
+
"status": "ready" if all_healthy else "degraded",
|
|
136
|
+
"checks": checks,
|
|
137
|
+
},
|
|
138
|
+
status_code=status_code,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def create_http_app() -> Starlette:
|
|
143
|
+
"""Create configured Starlette application wrapping FastMCP with auth and operational endpoints."""
|
|
144
|
+
settings = get_settings()
|
|
145
|
+
server = create_server()
|
|
146
|
+
|
|
147
|
+
# FastMCP streamable HTTP app mounted at configured path (default: /mcp)
|
|
148
|
+
app = server.http_app(
|
|
149
|
+
path=settings.mcp.streamable_http_path,
|
|
150
|
+
transport="streamable-http",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Register health probes
|
|
154
|
+
app.add_route("/health/live", health_live, methods=["GET"])
|
|
155
|
+
app.add_route("/health/ready", health_ready, methods=["GET"])
|
|
156
|
+
|
|
157
|
+
# Register BearerAuthMiddleware
|
|
158
|
+
app.add_middleware(BearerAuthMiddleware)
|
|
159
|
+
|
|
160
|
+
return app
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run_http() -> None:
|
|
164
|
+
"""CLI entrypoint for running the ModelLab HTTP server using uvicorn."""
|
|
165
|
+
import uvicorn
|
|
166
|
+
|
|
167
|
+
from ml_mcp.infrastructure.telemetry.logging import configure_logging
|
|
168
|
+
|
|
169
|
+
configure_logging()
|
|
170
|
+
settings = get_settings()
|
|
171
|
+
|
|
172
|
+
uvicorn.run(
|
|
173
|
+
"ml_mcp.server.http:create_http_app",
|
|
174
|
+
factory=True,
|
|
175
|
+
host=settings.mcp.host,
|
|
176
|
+
port=settings.mcp.port,
|
|
177
|
+
log_config=None, # Use structured stderr logger
|
|
178
|
+
access_log=False,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
run_http()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Security, authorization, and rate-limiting middleware pipeline."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ml_mcp.config import get_settings
|
|
7
|
+
from ml_mcp.domain.errors import ResourceLimitExceededError
|
|
8
|
+
from ml_mcp.domain.policies import (
|
|
9
|
+
TOOL_POLICIES,
|
|
10
|
+
Principal,
|
|
11
|
+
Role,
|
|
12
|
+
)
|
|
13
|
+
from ml_mcp.infrastructure.postgres.models import AuditEventOrm
|
|
14
|
+
from ml_mcp.infrastructure.postgres.repositories.audit import AuditRepository
|
|
15
|
+
from ml_mcp.infrastructure.postgres.session import get_db_manager
|
|
16
|
+
from ml_mcp.infrastructure.redis.rate_limiter import SlidingWindowRateLimiter
|
|
17
|
+
from ml_mcp.infrastructure.telemetry.logging import (
|
|
18
|
+
clear_request_context,
|
|
19
|
+
set_request_context,
|
|
20
|
+
)
|
|
21
|
+
from ml_mcp.infrastructure.telemetry.otel import get_instruments
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Default local principal for STDIO operations (trusted local process boundary)
|
|
27
|
+
LOCAL_STDIO_PRINCIPAL = Principal(
|
|
28
|
+
principal_id="local-user",
|
|
29
|
+
tenant_id="default-tenant",
|
|
30
|
+
role=Role.ADMIN,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SecurityPipeline:
|
|
35
|
+
"""Orchestrates authentication, scope checks, rate limiting, and audit logging for tool executions."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, rate_limiter: SlidingWindowRateLimiter | None = None) -> None:
|
|
38
|
+
self.rate_limiter = rate_limiter or SlidingWindowRateLimiter()
|
|
39
|
+
|
|
40
|
+
async def pre_tool_call(
|
|
41
|
+
self,
|
|
42
|
+
tool_name: str,
|
|
43
|
+
principal: Principal,
|
|
44
|
+
request_id: str | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Enforce rate limits, scopes, and set request context."""
|
|
47
|
+
policy = TOOL_POLICIES.get(tool_name)
|
|
48
|
+
if not policy:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# 1. Telemetry context
|
|
52
|
+
set_request_context(
|
|
53
|
+
request_id=request_id,
|
|
54
|
+
tenant_id=principal.tenant_id,
|
|
55
|
+
principal_id=principal.principal_id,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# 2. Scope enforcement
|
|
59
|
+
principal.enforce_permission(policy.required_scope)
|
|
60
|
+
|
|
61
|
+
# 3. Rate limiting enforcement
|
|
62
|
+
settings = get_settings()
|
|
63
|
+
limits_map = {
|
|
64
|
+
"read": settings.rate_limit.read_per_minute,
|
|
65
|
+
"metadata": settings.rate_limit.metadata_per_minute,
|
|
66
|
+
"experiment": settings.rate_limit.experiment_per_minute,
|
|
67
|
+
"analysis": settings.rate_limit.analysis_per_minute,
|
|
68
|
+
}
|
|
69
|
+
limit = limits_map.get(policy.rate_limit_category, 60)
|
|
70
|
+
res = await self.rate_limiter.check_rate_limit(
|
|
71
|
+
identifier=f"{principal.tenant_id}:{principal.principal_id}",
|
|
72
|
+
category=policy.rate_limit_category,
|
|
73
|
+
limit=limit,
|
|
74
|
+
)
|
|
75
|
+
if not res.allowed:
|
|
76
|
+
get_instruments().rate_limit_denials_total.add(1)
|
|
77
|
+
raise ResourceLimitExceededError(
|
|
78
|
+
f"Rate limit exceeded for tool category '{policy.rate_limit_category}'",
|
|
79
|
+
limit=limit,
|
|
80
|
+
current=limit + 1,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def post_tool_call(
|
|
84
|
+
self,
|
|
85
|
+
tool_name: str,
|
|
86
|
+
principal: Principal,
|
|
87
|
+
result: Any,
|
|
88
|
+
success: bool = True,
|
|
89
|
+
error_code: str | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Emit audit log entry if required by policy and clear telemetry context."""
|
|
92
|
+
policy = TOOL_POLICIES.get(tool_name)
|
|
93
|
+
if policy and policy.requires_audit:
|
|
94
|
+
try:
|
|
95
|
+
async with get_db_manager().session() as sess:
|
|
96
|
+
audit_repo = AuditRepository(sess)
|
|
97
|
+
await audit_repo.record_event(
|
|
98
|
+
AuditEventOrm(
|
|
99
|
+
principal_id=principal.principal_id,
|
|
100
|
+
tenant_id=principal.tenant_id,
|
|
101
|
+
action=f"TOOL_{tool_name.upper()}",
|
|
102
|
+
resource_type="tool",
|
|
103
|
+
resource_id=tool_name,
|
|
104
|
+
result="SUCCESS" if success else "ERROR",
|
|
105
|
+
reason_code=error_code,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
logger.warning("Failed to record audit event for %s: %s", tool_name, exc)
|
|
110
|
+
|
|
111
|
+
clear_request_context()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_pipeline: SecurityPipeline | None = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_security_pipeline() -> SecurityPipeline:
|
|
118
|
+
global _pipeline
|
|
119
|
+
if _pipeline is None:
|
|
120
|
+
_pipeline = SecurityPipeline()
|
|
121
|
+
return _pipeline
|
ml_mcp/server/stdio.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""STDIO transport runner for ModelLab FastMCP Server.
|
|
2
|
+
|
|
3
|
+
Adheres strictly to standard stream discipline:
|
|
4
|
+
- stdout: Exclusively reserved for valid MCP JSON-RPC protocol frames.
|
|
5
|
+
- stderr: All structured JSON logs, diagnostic output, and trace messages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from ml_mcp.infrastructure.telemetry.logging import configure_logging
|
|
13
|
+
from ml_mcp.server.app import create_server
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def run_stdio_server() -> None:
|
|
19
|
+
"""Initialize server components and launch FastMCP STDIO loop."""
|
|
20
|
+
# Strict stream discipline: logs go to stderr ONLY
|
|
21
|
+
configure_logging()
|
|
22
|
+
logger.info("Initializing ModelLab FastMCP server on STDIO transport...")
|
|
23
|
+
|
|
24
|
+
server = create_server("ModelLab-STDIO")
|
|
25
|
+
|
|
26
|
+
# Run FastMCP stdio server
|
|
27
|
+
await server.run_stdio_async()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_stdio() -> None:
|
|
31
|
+
"""Synchronous entrypoint for STDIO launcher."""
|
|
32
|
+
try:
|
|
33
|
+
asyncio.run(run_stdio_server())
|
|
34
|
+
except (KeyboardInterrupt, SystemExit):
|
|
35
|
+
sys.stderr.write("ModelLab STDIO server terminated gracefully.\n")
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
sys.stderr.write(f"Fatal error in ModelLab STDIO server: {exc}\n")
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
run_stdio()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Isolated ML worker package."""
|
ml_mcp/workers/daemon.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Worker daemon dequeuing and executing ML experiment tasks from Redis."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ml_mcp.infrastructure.object_storage.s3 import S3StorageService, get_storage_service
|
|
10
|
+
from ml_mcp.infrastructure.postgres.session import DatabaseManager, get_db_manager
|
|
11
|
+
from ml_mcp.infrastructure.queue.task_queue import RedisTaskQueue
|
|
12
|
+
from ml_mcp.infrastructure.telemetry.logging import configure_logging
|
|
13
|
+
from ml_mcp.workers.runner import WorkerRunner
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WorkerDaemon:
|
|
19
|
+
"""Asynchronous background worker daemon executing experiment tasks from Redis."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
task_queue: RedisTaskQueue | None = None,
|
|
24
|
+
storage_service: S3StorageService | None = None,
|
|
25
|
+
db_manager: DatabaseManager | None = None,
|
|
26
|
+
drain_outbox: bool = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.queue = task_queue or RedisTaskQueue()
|
|
29
|
+
self.storage = storage_service or get_storage_service()
|
|
30
|
+
self.db_mgr = db_manager or get_db_manager()
|
|
31
|
+
self.drain_outbox = drain_outbox
|
|
32
|
+
self._stop_event = asyncio.Event()
|
|
33
|
+
self._running = False
|
|
34
|
+
|
|
35
|
+
def stop(self, *args: Any) -> None:
|
|
36
|
+
"""Signal the daemon loop to terminate gracefully."""
|
|
37
|
+
logger.info("Shutdown signal received; initiating graceful termination of worker daemon...")
|
|
38
|
+
self._stop_event.set()
|
|
39
|
+
|
|
40
|
+
async def run(self) -> None:
|
|
41
|
+
"""Run continuous dequeue-execute loop until stopped."""
|
|
42
|
+
self._running = True
|
|
43
|
+
logger.info("Worker daemon started, polling for experiment tasks...")
|
|
44
|
+
|
|
45
|
+
while not self._stop_event.is_set():
|
|
46
|
+
try:
|
|
47
|
+
# 1. Drain pending outbox events into Redis queue if enabled
|
|
48
|
+
if self.drain_outbox:
|
|
49
|
+
try:
|
|
50
|
+
async with self.db_mgr.session() as session:
|
|
51
|
+
from ml_mcp.infrastructure.queue.outbox_processor import OutboxProcessor
|
|
52
|
+
|
|
53
|
+
processor = OutboxProcessor(session, self.queue)
|
|
54
|
+
await processor.process_batch(limit=20)
|
|
55
|
+
except Exception as outbox_exc:
|
|
56
|
+
logger.debug("Outbox drain cycle check: %s", outbox_exc)
|
|
57
|
+
|
|
58
|
+
# 2. Dequeue experiment task
|
|
59
|
+
task_message = await self.queue.dequeue(timeout_seconds=2)
|
|
60
|
+
if task_message is None:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
logger.info(
|
|
64
|
+
"Dequeued task %s for experiment %s (tenant %s)",
|
|
65
|
+
task_message.task_id,
|
|
66
|
+
task_message.experiment_id,
|
|
67
|
+
task_message.tenant_id,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async with self.db_mgr.session() as session:
|
|
71
|
+
runner = WorkerRunner(session=session, storage_service=self.storage)
|
|
72
|
+
try:
|
|
73
|
+
result = await runner.execute_experiment(
|
|
74
|
+
experiment_id=task_message.experiment_id,
|
|
75
|
+
tenant_id=task_message.tenant_id,
|
|
76
|
+
)
|
|
77
|
+
logger.info(
|
|
78
|
+
"Task %s succeeded for experiment %s: %s",
|
|
79
|
+
task_message.task_id,
|
|
80
|
+
task_message.experiment_id,
|
|
81
|
+
result.get("status"),
|
|
82
|
+
)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
logger.error(
|
|
85
|
+
"Task %s failed for experiment %s: %s",
|
|
86
|
+
task_message.task_id,
|
|
87
|
+
task_message.experiment_id,
|
|
88
|
+
exc,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
except asyncio.CancelledError:
|
|
92
|
+
logger.info("Worker daemon cancelled")
|
|
93
|
+
break
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
logger.error("Unexpected error in worker daemon polling loop: %s", exc)
|
|
96
|
+
# Brief sleep on unexpected error to prevent rapid spinning
|
|
97
|
+
await asyncio.sleep(1)
|
|
98
|
+
|
|
99
|
+
self._running = False
|
|
100
|
+
logger.info("Worker daemon stopped cleanly.")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _register_signals(daemon: WorkerDaemon) -> None:
|
|
104
|
+
"""Attach graceful shutdown handlers for SIGINT and SIGTERM."""
|
|
105
|
+
loop = asyncio.get_running_loop()
|
|
106
|
+
signals = [signal.SIGINT]
|
|
107
|
+
if hasattr(signal, "SIGTERM"):
|
|
108
|
+
signals.append(signal.SIGTERM)
|
|
109
|
+
|
|
110
|
+
for sig in signals:
|
|
111
|
+
try:
|
|
112
|
+
loop.add_signal_handler(sig, daemon.stop)
|
|
113
|
+
except (NotImplementedError, AttributeError):
|
|
114
|
+
# Fallback for platforms without loop.add_signal_handler (e.g. Windows)
|
|
115
|
+
signal.signal(sig, lambda s, f: daemon.stop())
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def main() -> None:
|
|
119
|
+
"""Daemon CLI entrypoint."""
|
|
120
|
+
configure_logging()
|
|
121
|
+
daemon = WorkerDaemon()
|
|
122
|
+
_register_signals(daemon)
|
|
123
|
+
await daemon.run()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
try:
|
|
128
|
+
asyncio.run(main())
|
|
129
|
+
except (KeyboardInterrupt, SystemExit):
|
|
130
|
+
sys.exit(0)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Comprehensive evaluation metrics computation engine for classification and regression."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from sklearn.metrics import (
|
|
7
|
+
accuracy_score,
|
|
8
|
+
average_precision_score,
|
|
9
|
+
brier_score_loss,
|
|
10
|
+
confusion_matrix,
|
|
11
|
+
explained_variance_score,
|
|
12
|
+
f1_score,
|
|
13
|
+
log_loss,
|
|
14
|
+
mean_absolute_error,
|
|
15
|
+
mean_absolute_percentage_error,
|
|
16
|
+
mean_squared_error,
|
|
17
|
+
precision_score,
|
|
18
|
+
r2_score,
|
|
19
|
+
recall_score,
|
|
20
|
+
roc_auc_score,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from ml_mcp.domain.value_objects import TaskType
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MetricsCalculator:
|
|
27
|
+
"""Calculates all standard ML evaluation metrics without placeholders or approximations."""
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def compute_classification_metrics(
|
|
31
|
+
y_true: np.ndarray,
|
|
32
|
+
y_pred: np.ndarray,
|
|
33
|
+
y_proba: np.ndarray | None = None,
|
|
34
|
+
task_type: TaskType = TaskType.BINARY_CLASSIFICATION,
|
|
35
|
+
) -> dict[str, float | list[list[int]]]:
|
|
36
|
+
"""Compute full classification metric suite."""
|
|
37
|
+
metrics: dict[str, float | list[list[int]]] = {}
|
|
38
|
+
|
|
39
|
+
metrics["accuracy"] = float(accuracy_score(y_true, y_pred))
|
|
40
|
+
metrics["f1"] = float(
|
|
41
|
+
f1_score(
|
|
42
|
+
y_true,
|
|
43
|
+
y_pred,
|
|
44
|
+
average="weighted" if task_type == TaskType.MULTICLASS_CLASSIFICATION else "binary",
|
|
45
|
+
zero_division=0,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
metrics["f1_macro"] = float(f1_score(y_true, y_pred, average="macro", zero_division=0))
|
|
49
|
+
metrics["precision"] = float(
|
|
50
|
+
precision_score(
|
|
51
|
+
y_true,
|
|
52
|
+
y_pred,
|
|
53
|
+
average="weighted" if task_type == TaskType.MULTICLASS_CLASSIFICATION else "binary",
|
|
54
|
+
zero_division=0,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
metrics["recall"] = float(
|
|
58
|
+
recall_score(
|
|
59
|
+
y_true,
|
|
60
|
+
y_pred,
|
|
61
|
+
average="weighted" if task_type == TaskType.MULTICLASS_CLASSIFICATION else "binary",
|
|
62
|
+
zero_division=0,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Confusion matrix
|
|
67
|
+
cm = confusion_matrix(y_true, y_pred)
|
|
68
|
+
metrics["confusion_matrix"] = cm.tolist()
|
|
69
|
+
|
|
70
|
+
# Probability-based metrics
|
|
71
|
+
if y_proba is not None:
|
|
72
|
+
with contextlib.suppress(Exception):
|
|
73
|
+
if task_type == TaskType.BINARY_CLASSIFICATION:
|
|
74
|
+
# y_proba can be 1D or 2D
|
|
75
|
+
proba_pos = (
|
|
76
|
+
y_proba[:, 1] if y_proba.ndim == 2 and y_proba.shape[1] > 1 else y_proba
|
|
77
|
+
)
|
|
78
|
+
metrics["roc_auc"] = float(roc_auc_score(y_true, proba_pos))
|
|
79
|
+
metrics["pr_auc"] = float(average_precision_score(y_true, proba_pos))
|
|
80
|
+
metrics["brier_score"] = float(brier_score_loss(y_true, proba_pos))
|
|
81
|
+
metrics["log_loss"] = float(log_loss(y_true, y_proba))
|
|
82
|
+
elif task_type == TaskType.MULTICLASS_CLASSIFICATION:
|
|
83
|
+
metrics["roc_auc"] = float(roc_auc_score(y_true, y_proba, multi_class="ovr"))
|
|
84
|
+
metrics["log_loss"] = float(log_loss(y_true, y_proba))
|
|
85
|
+
|
|
86
|
+
return metrics
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def compute_regression_metrics(
|
|
90
|
+
y_true: np.ndarray,
|
|
91
|
+
y_pred: np.ndarray,
|
|
92
|
+
) -> dict[str, float]:
|
|
93
|
+
"""Compute full regression metric suite."""
|
|
94
|
+
mse = float(mean_squared_error(y_true, y_pred))
|
|
95
|
+
rmse = float(np.sqrt(mse))
|
|
96
|
+
mae = float(mean_absolute_error(y_true, y_pred))
|
|
97
|
+
r2 = float(r2_score(y_true, y_pred))
|
|
98
|
+
|
|
99
|
+
metrics: dict[str, float] = {
|
|
100
|
+
"mse": mse,
|
|
101
|
+
"rmse": rmse,
|
|
102
|
+
"mae": mae,
|
|
103
|
+
"r2": r2,
|
|
104
|
+
"explained_variance": float(explained_variance_score(y_true, y_pred)),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
with contextlib.suppress(Exception):
|
|
108
|
+
metrics["mape"] = float(mean_absolute_percentage_error(y_true, y_pred))
|
|
109
|
+
|
|
110
|
+
return metrics
|