flowyml 1.7.2__py3-none-any.whl → 1.8.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.
- flowyml/assets/base.py +15 -0
- flowyml/assets/metrics.py +5 -0
- flowyml/cli/main.py +709 -0
- flowyml/cli/stack_cli.py +138 -25
- flowyml/core/__init__.py +17 -0
- flowyml/core/executor.py +161 -26
- flowyml/core/image_builder.py +129 -0
- flowyml/core/log_streamer.py +227 -0
- flowyml/core/orchestrator.py +22 -2
- flowyml/core/pipeline.py +34 -10
- flowyml/core/routing.py +558 -0
- flowyml/core/step.py +9 -1
- flowyml/core/step_grouping.py +49 -35
- flowyml/core/types.py +407 -0
- flowyml/monitoring/alerts.py +10 -0
- flowyml/monitoring/notifications.py +104 -25
- flowyml/monitoring/slack_blocks.py +323 -0
- flowyml/plugins/__init__.py +251 -0
- flowyml/plugins/alerters/__init__.py +1 -0
- flowyml/plugins/alerters/slack.py +168 -0
- flowyml/plugins/base.py +752 -0
- flowyml/plugins/config.py +478 -0
- flowyml/plugins/deployers/__init__.py +22 -0
- flowyml/plugins/deployers/gcp_cloud_run.py +200 -0
- flowyml/plugins/deployers/sagemaker.py +306 -0
- flowyml/plugins/deployers/vertex.py +290 -0
- flowyml/plugins/integration.py +369 -0
- flowyml/plugins/manager.py +510 -0
- flowyml/plugins/model_registries/__init__.py +22 -0
- flowyml/plugins/model_registries/mlflow.py +159 -0
- flowyml/plugins/model_registries/sagemaker.py +489 -0
- flowyml/plugins/model_registries/vertex.py +386 -0
- flowyml/plugins/orchestrators/__init__.py +13 -0
- flowyml/plugins/orchestrators/sagemaker.py +443 -0
- flowyml/plugins/orchestrators/vertex_ai.py +461 -0
- flowyml/plugins/registries/__init__.py +13 -0
- flowyml/plugins/registries/ecr.py +321 -0
- flowyml/plugins/registries/gcr.py +313 -0
- flowyml/plugins/registry.py +454 -0
- flowyml/plugins/stack.py +494 -0
- flowyml/plugins/stack_config.py +537 -0
- flowyml/plugins/stores/__init__.py +13 -0
- flowyml/plugins/stores/gcs.py +460 -0
- flowyml/plugins/stores/s3.py +453 -0
- flowyml/plugins/trackers/__init__.py +11 -0
- flowyml/plugins/trackers/mlflow.py +316 -0
- flowyml/plugins/validators/__init__.py +3 -0
- flowyml/plugins/validators/deepchecks.py +119 -0
- flowyml/registry/__init__.py +2 -1
- flowyml/registry/model_environment.py +109 -0
- flowyml/registry/model_registry.py +241 -96
- flowyml/serving/__init__.py +17 -0
- flowyml/serving/model_server.py +628 -0
- flowyml/stacks/__init__.py +60 -0
- flowyml/stacks/aws.py +93 -0
- flowyml/stacks/base.py +62 -0
- flowyml/stacks/components.py +12 -0
- flowyml/stacks/gcp.py +44 -9
- flowyml/stacks/plugins.py +115 -0
- flowyml/stacks/registry.py +2 -1
- flowyml/storage/sql.py +401 -12
- flowyml/tracking/experiment.py +8 -5
- flowyml/ui/backend/Dockerfile +87 -16
- flowyml/ui/backend/auth.py +12 -2
- flowyml/ui/backend/main.py +149 -5
- flowyml/ui/backend/routers/ai_context.py +226 -0
- flowyml/ui/backend/routers/assets.py +23 -4
- flowyml/ui/backend/routers/auth.py +96 -0
- flowyml/ui/backend/routers/deployments.py +660 -0
- flowyml/ui/backend/routers/model_explorer.py +597 -0
- flowyml/ui/backend/routers/plugins.py +103 -51
- flowyml/ui/backend/routers/projects.py +91 -8
- flowyml/ui/backend/routers/runs.py +20 -1
- flowyml/ui/backend/routers/schedules.py +22 -17
- flowyml/ui/backend/routers/templates.py +319 -0
- flowyml/ui/backend/routers/websocket.py +2 -2
- flowyml/ui/frontend/Dockerfile +55 -6
- flowyml/ui/frontend/dist/assets/index-B5AsPTSz.css +1 -0
- flowyml/ui/frontend/dist/assets/index-dFbZ8wD8.js +753 -0
- flowyml/ui/frontend/dist/index.html +2 -2
- flowyml/ui/frontend/dist/logo.png +0 -0
- flowyml/ui/frontend/nginx.conf +65 -4
- flowyml/ui/frontend/package-lock.json +1404 -74
- flowyml/ui/frontend/package.json +3 -0
- flowyml/ui/frontend/public/logo.png +0 -0
- flowyml/ui/frontend/src/App.jsx +10 -7
- flowyml/ui/frontend/src/app/auth/Login.jsx +90 -0
- flowyml/ui/frontend/src/app/dashboard/page.jsx +8 -8
- flowyml/ui/frontend/src/app/deployments/page.jsx +786 -0
- flowyml/ui/frontend/src/app/model-explorer/page.jsx +1031 -0
- flowyml/ui/frontend/src/app/pipelines/page.jsx +12 -2
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +19 -6
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +36 -24
- flowyml/ui/frontend/src/app/runs/page.jsx +8 -2
- flowyml/ui/frontend/src/app/settings/page.jsx +267 -253
- flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +29 -7
- flowyml/ui/frontend/src/components/Layout.jsx +6 -0
- flowyml/ui/frontend/src/components/PipelineGraph.jsx +79 -29
- flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +36 -6
- flowyml/ui/frontend/src/components/RunMetaPanel.jsx +113 -0
- flowyml/ui/frontend/src/components/ai/AIAssistantButton.jsx +71 -0
- flowyml/ui/frontend/src/components/ai/AIAssistantPanel.jsx +420 -0
- flowyml/ui/frontend/src/components/header/Header.jsx +22 -0
- flowyml/ui/frontend/src/components/plugins/PluginManager.jsx +4 -4
- flowyml/ui/frontend/src/components/plugins/{ZenMLIntegration.jsx → StackImport.jsx} +38 -12
- flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +36 -13
- flowyml/ui/frontend/src/contexts/AIAssistantContext.jsx +245 -0
- flowyml/ui/frontend/src/contexts/AuthContext.jsx +108 -0
- flowyml/ui/frontend/src/hooks/useAIContext.js +156 -0
- flowyml/ui/frontend/src/hooks/useWebGPU.js +54 -0
- flowyml/ui/frontend/src/layouts/MainLayout.jsx +6 -0
- flowyml/ui/frontend/src/router/index.jsx +47 -20
- flowyml/ui/frontend/src/services/pluginService.js +3 -1
- flowyml/ui/server_manager.py +5 -5
- flowyml/ui/utils.py +157 -39
- flowyml/utils/config.py +37 -15
- flowyml/utils/model_introspection.py +123 -0
- flowyml/utils/observability.py +30 -0
- flowyml-1.8.0.dist-info/METADATA +174 -0
- {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/RECORD +123 -65
- {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/WHEEL +1 -1
- flowyml/ui/frontend/dist/assets/index-B40RsQDq.css +0 -1
- flowyml/ui/frontend/dist/assets/index-CjI0zKCn.js +0 -685
- flowyml-1.7.2.dist-info/METADATA +0 -477
- {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/entry_points.txt +0 -0
- {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"""Model Explorer API for interactive model testing."""
|
|
2
|
+
from fastapi import APIRouter, HTTPException
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from typing import Any
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/explorer", tags=["model-explorer"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ==================== Schemas ====================
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InputFieldSchema(BaseModel):
|
|
15
|
+
"""Schema for a single input field."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
type: str # noqa: A003 # number, integer, string, boolean, array, object
|
|
19
|
+
description: str | None = None
|
|
20
|
+
default: Any | None = None
|
|
21
|
+
min_value: float | None = None
|
|
22
|
+
max_value: float | None = None
|
|
23
|
+
step: float | None = None
|
|
24
|
+
enum: list[Any] | None = None # For categorical inputs
|
|
25
|
+
required: bool = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ModelSchema(BaseModel):
|
|
29
|
+
"""Schema describing model inputs and outputs."""
|
|
30
|
+
|
|
31
|
+
model_id: str
|
|
32
|
+
model_name: str
|
|
33
|
+
model_type: str
|
|
34
|
+
inputs: list[InputFieldSchema]
|
|
35
|
+
outputs: list[InputFieldSchema]
|
|
36
|
+
example_input: dict | None = None
|
|
37
|
+
example_output: dict | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PredictionRequest(BaseModel):
|
|
41
|
+
"""Request for a single prediction."""
|
|
42
|
+
|
|
43
|
+
deployment_id: str | None = None
|
|
44
|
+
model_artifact_id: str | None = None
|
|
45
|
+
inputs: dict[str, Any]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PredictionResult(BaseModel):
|
|
49
|
+
"""Result of a single prediction."""
|
|
50
|
+
|
|
51
|
+
id: str # noqa: A003
|
|
52
|
+
inputs: dict[str, Any]
|
|
53
|
+
outputs: dict[str, Any]
|
|
54
|
+
latency_ms: float
|
|
55
|
+
timestamp: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SweepRequest(BaseModel):
|
|
59
|
+
"""Request for parameter sweep."""
|
|
60
|
+
|
|
61
|
+
deployment_id: str | None = None
|
|
62
|
+
model_artifact_id: str | None = None
|
|
63
|
+
base_inputs: dict[str, Any]
|
|
64
|
+
sweep_param: str
|
|
65
|
+
sweep_values: list[Any]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SweepResult(BaseModel):
|
|
69
|
+
"""Result of parameter sweep."""
|
|
70
|
+
|
|
71
|
+
id: str # noqa: A003
|
|
72
|
+
sweep_param: str
|
|
73
|
+
results: list[dict] # [{input_value, outputs, latency_ms}]
|
|
74
|
+
total_latency_ms: float
|
|
75
|
+
timestamp: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ExplorationSession(BaseModel):
|
|
79
|
+
"""An exploration session with prediction history."""
|
|
80
|
+
|
|
81
|
+
id: str # noqa: A003
|
|
82
|
+
model_id: str
|
|
83
|
+
model_name: str
|
|
84
|
+
created_at: str
|
|
85
|
+
predictions: list[PredictionResult]
|
|
86
|
+
sweeps: list[SweepResult]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ==================== In-Memory State ====================
|
|
90
|
+
|
|
91
|
+
_sessions: dict[str, dict] = {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ==================== Endpoints ====================
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.get("/schema/{model_id}")
|
|
98
|
+
async def get_model_schema(model_id: str) -> ModelSchema:
|
|
99
|
+
"""Get the input/output schema for a model.
|
|
100
|
+
|
|
101
|
+
This introspects the model to determine its expected inputs and outputs.
|
|
102
|
+
"""
|
|
103
|
+
# TODO: Actually introspect the model
|
|
104
|
+
# For MVP, return mock schema based on common patterns
|
|
105
|
+
|
|
106
|
+
# Try to determine model type from artifacts
|
|
107
|
+
from flowyml.ui.backend.dependencies import get_store
|
|
108
|
+
|
|
109
|
+
store = get_store()
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
artifacts = store.list_artifacts()
|
|
113
|
+
model_artifact = next(
|
|
114
|
+
(a for a in artifacts if a.get("artifact_id") == model_id),
|
|
115
|
+
None,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if not model_artifact:
|
|
119
|
+
raise HTTPException(status_code=404, detail="Model not found")
|
|
120
|
+
|
|
121
|
+
model_type = model_artifact.get("asset_type", "unknown")
|
|
122
|
+
model_name = model_artifact.get("name", "Unknown Model")
|
|
123
|
+
|
|
124
|
+
except StopIteration:
|
|
125
|
+
raise HTTPException(status_code=404, detail="Model not found")
|
|
126
|
+
except Exception:
|
|
127
|
+
model_type = "unknown"
|
|
128
|
+
model_name = "Unknown Model"
|
|
129
|
+
|
|
130
|
+
# Generate schema based on model type
|
|
131
|
+
# In production, this would introspect the actual model
|
|
132
|
+
inputs = _infer_input_schema(model_type)
|
|
133
|
+
outputs = _infer_output_schema(model_type)
|
|
134
|
+
|
|
135
|
+
return ModelSchema(
|
|
136
|
+
model_id=model_id,
|
|
137
|
+
model_name=model_name,
|
|
138
|
+
model_type=model_type,
|
|
139
|
+
inputs=inputs,
|
|
140
|
+
outputs=outputs,
|
|
141
|
+
example_input=_generate_example_input(inputs),
|
|
142
|
+
example_output=_generate_example_output(outputs),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@router.post("/predict")
|
|
147
|
+
async def predict(request: PredictionRequest) -> PredictionResult:
|
|
148
|
+
"""Run a prediction with the given inputs."""
|
|
149
|
+
import time
|
|
150
|
+
|
|
151
|
+
# Validate we have either deployment or model artifact
|
|
152
|
+
if not request.deployment_id and not request.model_artifact_id:
|
|
153
|
+
raise HTTPException(
|
|
154
|
+
status_code=400,
|
|
155
|
+
detail="Either deployment_id or model_artifact_id required",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
start = time.time()
|
|
159
|
+
prediction_id = str(uuid4())
|
|
160
|
+
|
|
161
|
+
# Try real prediction if we have a deployment_id
|
|
162
|
+
if request.deployment_id:
|
|
163
|
+
try:
|
|
164
|
+
from flowyml.serving.model_server import predict as model_predict, get_server
|
|
165
|
+
|
|
166
|
+
server = get_server(request.deployment_id)
|
|
167
|
+
if server is not None:
|
|
168
|
+
# Use real model prediction
|
|
169
|
+
import asyncio
|
|
170
|
+
|
|
171
|
+
loop = asyncio.get_event_loop()
|
|
172
|
+
outputs = await loop.run_in_executor(
|
|
173
|
+
None,
|
|
174
|
+
lambda: model_predict(request.deployment_id, request.inputs),
|
|
175
|
+
)
|
|
176
|
+
latency = (time.time() - start) * 1000
|
|
177
|
+
|
|
178
|
+
result = PredictionResult(
|
|
179
|
+
id=prediction_id,
|
|
180
|
+
inputs=request.inputs,
|
|
181
|
+
outputs=outputs,
|
|
182
|
+
latency_ms=latency,
|
|
183
|
+
timestamp=datetime.now().isoformat(),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Store in session
|
|
187
|
+
session_id = request.deployment_id
|
|
188
|
+
if session_id in _sessions:
|
|
189
|
+
_sessions[session_id]["predictions"].append(result.model_dump())
|
|
190
|
+
|
|
191
|
+
return result
|
|
192
|
+
except Exception as e:
|
|
193
|
+
# Return error with details instead of falling back to mock
|
|
194
|
+
raise HTTPException(
|
|
195
|
+
status_code=500,
|
|
196
|
+
detail=f"Prediction failed for deployment {request.deployment_id}: {str(e)}",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Try direct model loading for artifact
|
|
200
|
+
if request.model_artifact_id:
|
|
201
|
+
try:
|
|
202
|
+
from flowyml.serving.model_server import load_and_predict
|
|
203
|
+
import asyncio
|
|
204
|
+
|
|
205
|
+
loop = asyncio.get_event_loop()
|
|
206
|
+
outputs, _ = await loop.run_in_executor(
|
|
207
|
+
None,
|
|
208
|
+
lambda: load_and_predict(request.model_artifact_id, request.inputs),
|
|
209
|
+
)
|
|
210
|
+
latency = (time.time() - start) * 1000
|
|
211
|
+
|
|
212
|
+
result = PredictionResult(
|
|
213
|
+
id=prediction_id,
|
|
214
|
+
inputs=request.inputs,
|
|
215
|
+
outputs=outputs,
|
|
216
|
+
latency_ms=latency,
|
|
217
|
+
timestamp=datetime.now().isoformat(),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
session_id = request.model_artifact_id
|
|
221
|
+
if session_id in _sessions:
|
|
222
|
+
_sessions[session_id]["predictions"].append(result.model_dump())
|
|
223
|
+
|
|
224
|
+
return result
|
|
225
|
+
except Exception as e:
|
|
226
|
+
# Return error with details
|
|
227
|
+
raise HTTPException(
|
|
228
|
+
status_code=500,
|
|
229
|
+
detail=f"Prediction failed for artifact {request.model_artifact_id}: {str(e)}",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# No deployment or artifact - return helpful error
|
|
233
|
+
raise HTTPException(
|
|
234
|
+
status_code=400,
|
|
235
|
+
detail="Either deployment_id or model_artifact_id required, and must be running",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@router.post("/sweep")
|
|
240
|
+
async def parameter_sweep(request: SweepRequest) -> SweepResult:
|
|
241
|
+
"""Run a parameter sweep over a range of values."""
|
|
242
|
+
import time
|
|
243
|
+
|
|
244
|
+
if not request.deployment_id and not request.model_artifact_id:
|
|
245
|
+
raise HTTPException(
|
|
246
|
+
status_code=400,
|
|
247
|
+
detail="Either deployment_id or model_artifact_id required",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
sweep_id = str(uuid4())
|
|
251
|
+
results = []
|
|
252
|
+
total_start = time.time()
|
|
253
|
+
|
|
254
|
+
for value in request.sweep_values:
|
|
255
|
+
start = time.time()
|
|
256
|
+
|
|
257
|
+
# Create input with swept parameter
|
|
258
|
+
inputs = request.base_inputs.copy()
|
|
259
|
+
inputs[request.sweep_param] = value
|
|
260
|
+
|
|
261
|
+
# Run prediction
|
|
262
|
+
outputs = _mock_predict(inputs)
|
|
263
|
+
latency = (time.time() - start) * 1000
|
|
264
|
+
|
|
265
|
+
results.append(
|
|
266
|
+
{
|
|
267
|
+
"input_value": value,
|
|
268
|
+
"outputs": outputs,
|
|
269
|
+
"latency_ms": latency,
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
total_latency = (time.time() - total_start) * 1000
|
|
274
|
+
|
|
275
|
+
sweep_result = SweepResult(
|
|
276
|
+
id=sweep_id,
|
|
277
|
+
sweep_param=request.sweep_param,
|
|
278
|
+
results=results,
|
|
279
|
+
total_latency_ms=total_latency,
|
|
280
|
+
timestamp=datetime.now().isoformat(),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Store in session
|
|
284
|
+
session_id = request.deployment_id or request.model_artifact_id
|
|
285
|
+
if session_id in _sessions:
|
|
286
|
+
_sessions[session_id]["sweeps"].append(sweep_result.model_dump())
|
|
287
|
+
|
|
288
|
+
return sweep_result
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@router.get("/sessions")
|
|
292
|
+
async def list_sessions() -> list[dict]:
|
|
293
|
+
"""List all exploration sessions."""
|
|
294
|
+
return [
|
|
295
|
+
{
|
|
296
|
+
"id": s["id"],
|
|
297
|
+
"model_id": s["model_id"],
|
|
298
|
+
"model_name": s["model_name"],
|
|
299
|
+
"created_at": s["created_at"],
|
|
300
|
+
"prediction_count": len(s["predictions"]),
|
|
301
|
+
"sweep_count": len(s["sweeps"]),
|
|
302
|
+
}
|
|
303
|
+
for s in _sessions.values()
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@router.post("/sessions")
|
|
308
|
+
async def create_session(
|
|
309
|
+
model_id: str,
|
|
310
|
+
model_name: str = "Model",
|
|
311
|
+
) -> dict:
|
|
312
|
+
"""Create a new exploration session."""
|
|
313
|
+
session_id = str(uuid4())
|
|
314
|
+
|
|
315
|
+
session = {
|
|
316
|
+
"id": session_id,
|
|
317
|
+
"model_id": model_id,
|
|
318
|
+
"model_name": model_name,
|
|
319
|
+
"created_at": datetime.now().isoformat(),
|
|
320
|
+
"predictions": [],
|
|
321
|
+
"sweeps": [],
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
_sessions[session_id] = session
|
|
325
|
+
|
|
326
|
+
return {"session_id": session_id}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@router.get("/sessions/{session_id}")
|
|
330
|
+
async def get_session(session_id: str) -> ExplorationSession:
|
|
331
|
+
"""Get exploration session details."""
|
|
332
|
+
if session_id not in _sessions:
|
|
333
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
334
|
+
|
|
335
|
+
session = _sessions[session_id]
|
|
336
|
+
return ExplorationSession(
|
|
337
|
+
id=session["id"],
|
|
338
|
+
model_id=session["model_id"],
|
|
339
|
+
model_name=session["model_name"],
|
|
340
|
+
created_at=session["created_at"],
|
|
341
|
+
predictions=[PredictionResult(**p) for p in session["predictions"]],
|
|
342
|
+
sweeps=[SweepResult(**s) for s in session["sweeps"]],
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@router.delete("/sessions/{session_id}")
|
|
347
|
+
async def delete_session(session_id: str) -> dict:
|
|
348
|
+
"""Delete an exploration session."""
|
|
349
|
+
if session_id not in _sessions:
|
|
350
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
351
|
+
|
|
352
|
+
del _sessions[session_id]
|
|
353
|
+
return {"status": "deleted", "session_id": session_id}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ==================== Helper Functions ====================
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _infer_input_schema(model_type: str) -> list[InputFieldSchema]:
|
|
360
|
+
"""Infer input schema based on model type."""
|
|
361
|
+
# Common ML model input patterns
|
|
362
|
+
if model_type.lower() in ("keras_model", "tensorflow"):
|
|
363
|
+
return [
|
|
364
|
+
InputFieldSchema(
|
|
365
|
+
name="feature_1",
|
|
366
|
+
type="number",
|
|
367
|
+
description="First input feature",
|
|
368
|
+
min_value=0,
|
|
369
|
+
max_value=100,
|
|
370
|
+
step=0.1,
|
|
371
|
+
default=50.0,
|
|
372
|
+
),
|
|
373
|
+
InputFieldSchema(
|
|
374
|
+
name="feature_2",
|
|
375
|
+
type="number",
|
|
376
|
+
description="Second input feature",
|
|
377
|
+
min_value=0,
|
|
378
|
+
max_value=100,
|
|
379
|
+
step=0.1,
|
|
380
|
+
default=50.0,
|
|
381
|
+
),
|
|
382
|
+
InputFieldSchema(
|
|
383
|
+
name="feature_3",
|
|
384
|
+
type="number",
|
|
385
|
+
description="Third input feature",
|
|
386
|
+
min_value=0,
|
|
387
|
+
max_value=100,
|
|
388
|
+
step=0.1,
|
|
389
|
+
default=50.0,
|
|
390
|
+
),
|
|
391
|
+
]
|
|
392
|
+
elif model_type.lower() in ("sklearn_model", "scikit-learn"):
|
|
393
|
+
return [
|
|
394
|
+
InputFieldSchema(
|
|
395
|
+
name="X",
|
|
396
|
+
type="array",
|
|
397
|
+
description="Feature array",
|
|
398
|
+
default=[[0.5, 0.5, 0.5]],
|
|
399
|
+
),
|
|
400
|
+
]
|
|
401
|
+
else:
|
|
402
|
+
# Generic schema
|
|
403
|
+
return [
|
|
404
|
+
InputFieldSchema(
|
|
405
|
+
name="input",
|
|
406
|
+
type="object",
|
|
407
|
+
description="Model input",
|
|
408
|
+
default={},
|
|
409
|
+
),
|
|
410
|
+
]
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _infer_output_schema(model_type: str) -> list[InputFieldSchema]:
|
|
414
|
+
"""Infer output schema based on model type."""
|
|
415
|
+
if model_type.lower() in ("keras_model", "tensorflow", "sklearn_model"):
|
|
416
|
+
return [
|
|
417
|
+
InputFieldSchema(
|
|
418
|
+
name="prediction",
|
|
419
|
+
type="number",
|
|
420
|
+
description="Predicted value",
|
|
421
|
+
),
|
|
422
|
+
InputFieldSchema(
|
|
423
|
+
name="confidence",
|
|
424
|
+
type="number",
|
|
425
|
+
description="Prediction confidence",
|
|
426
|
+
min_value=0,
|
|
427
|
+
max_value=1,
|
|
428
|
+
),
|
|
429
|
+
]
|
|
430
|
+
else:
|
|
431
|
+
return [
|
|
432
|
+
InputFieldSchema(
|
|
433
|
+
name="output",
|
|
434
|
+
type="object",
|
|
435
|
+
description="Model output",
|
|
436
|
+
),
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _generate_example_input(inputs: list[InputFieldSchema]) -> dict:
|
|
441
|
+
"""Generate example input based on schema."""
|
|
442
|
+
example = {}
|
|
443
|
+
for field in inputs:
|
|
444
|
+
if field.default is not None:
|
|
445
|
+
example[field.name] = field.default
|
|
446
|
+
elif field.type == "number":
|
|
447
|
+
example[field.name] = (field.min_value or 0) + ((field.max_value or 100) - (field.min_value or 0)) / 2
|
|
448
|
+
elif field.type == "integer":
|
|
449
|
+
example[field.name] = int((field.min_value or 0) + ((field.max_value or 100) - (field.min_value or 0)) / 2)
|
|
450
|
+
elif field.type == "string":
|
|
451
|
+
example[field.name] = ""
|
|
452
|
+
elif field.type == "boolean":
|
|
453
|
+
example[field.name] = False
|
|
454
|
+
elif field.type == "array":
|
|
455
|
+
example[field.name] = []
|
|
456
|
+
else:
|
|
457
|
+
example[field.name] = {}
|
|
458
|
+
return example
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _generate_example_output(outputs: list[InputFieldSchema]) -> dict:
|
|
462
|
+
"""Generate example output based on schema."""
|
|
463
|
+
example = {}
|
|
464
|
+
for field in outputs:
|
|
465
|
+
if field.type == "number":
|
|
466
|
+
example[field.name] = 0.5
|
|
467
|
+
elif field.type == "integer":
|
|
468
|
+
example[field.name] = 0
|
|
469
|
+
elif field.type == "string":
|
|
470
|
+
example[field.name] = "result"
|
|
471
|
+
elif field.type == "boolean":
|
|
472
|
+
example[field.name] = True
|
|
473
|
+
elif field.type == "array":
|
|
474
|
+
example[field.name] = []
|
|
475
|
+
else:
|
|
476
|
+
example[field.name] = {}
|
|
477
|
+
return example
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _mock_predict(inputs: dict) -> dict:
|
|
481
|
+
"""Mock prediction for testing."""
|
|
482
|
+
import random
|
|
483
|
+
|
|
484
|
+
# Simple mock: sum numeric inputs and add noise
|
|
485
|
+
numeric_sum = 0
|
|
486
|
+
for value in inputs.values():
|
|
487
|
+
if isinstance(value, (int, float)):
|
|
488
|
+
numeric_sum += value
|
|
489
|
+
elif isinstance(value, list):
|
|
490
|
+
for v in value:
|
|
491
|
+
if isinstance(v, (int, float)):
|
|
492
|
+
numeric_sum += v
|
|
493
|
+
elif isinstance(v, list):
|
|
494
|
+
numeric_sum += sum(x for x in v if isinstance(x, (int, float)))
|
|
495
|
+
|
|
496
|
+
# Add some randomness to simulate model behavior
|
|
497
|
+
prediction = numeric_sum * 0.1 + random.uniform(-0.1, 0.1)
|
|
498
|
+
confidence = 0.7 + random.uniform(0, 0.25)
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
"prediction": round(prediction, 4),
|
|
502
|
+
"confidence": round(min(confidence, 1.0), 4),
|
|
503
|
+
"class": "positive" if prediction > 0 else "negative",
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@router.get("/model-info/{deployment_id}")
|
|
508
|
+
async def get_model_info(deployment_id: str) -> dict:
|
|
509
|
+
"""Get real model information by introspecting the loaded model.
|
|
510
|
+
|
|
511
|
+
This returns actual input/output specs from the deployed model,
|
|
512
|
+
which the frontend can use to format inputs correctly.
|
|
513
|
+
"""
|
|
514
|
+
try:
|
|
515
|
+
from flowyml.serving.model_server import get_server
|
|
516
|
+
from flowyml.utils.model_introspection import introspect_model
|
|
517
|
+
|
|
518
|
+
server = get_server(deployment_id)
|
|
519
|
+
|
|
520
|
+
# Check if we have a real server with a model
|
|
521
|
+
if server and server.model is not None:
|
|
522
|
+
# Use the shared utility to introspect the model
|
|
523
|
+
info = introspect_model(server.model, server.framework)
|
|
524
|
+
|
|
525
|
+
# Add deployment-specific metadata
|
|
526
|
+
info.update(
|
|
527
|
+
{
|
|
528
|
+
"deployment_id": deployment_id,
|
|
529
|
+
"model_path": server.model_path,
|
|
530
|
+
"started_at": server.started_at.isoformat() if server.started_at else None,
|
|
531
|
+
},
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
return info
|
|
535
|
+
|
|
536
|
+
except Exception:
|
|
537
|
+
pass # Fall through to mock info
|
|
538
|
+
|
|
539
|
+
# Mock/Default info for non-running deployments (e.g. on-demand only)
|
|
540
|
+
return {
|
|
541
|
+
"deployment_id": deployment_id,
|
|
542
|
+
"framework": "keras", # Assume keras/tf for now as likely default
|
|
543
|
+
"input_features": 10,
|
|
544
|
+
"input_shape": [None, 10],
|
|
545
|
+
"output_shape": [None, 1],
|
|
546
|
+
"mock": True,
|
|
547
|
+
"note": "Model server not running - showing expected schema",
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@router.get("/logs/{deployment_id}")
|
|
552
|
+
async def get_deployment_logs(deployment_id: str, lines: int = 100) -> dict:
|
|
553
|
+
"""Get logs from a deployed model server.
|
|
554
|
+
|
|
555
|
+
Returns recent log entries for debugging and monitoring.
|
|
556
|
+
"""
|
|
557
|
+
from datetime import datetime
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
from flowyml.serving.model_server import get_server_logs, get_server
|
|
561
|
+
|
|
562
|
+
server = get_server(deployment_id)
|
|
563
|
+
if server:
|
|
564
|
+
logs = get_server_logs(deployment_id, lines)
|
|
565
|
+
return {
|
|
566
|
+
"deployment_id": deployment_id,
|
|
567
|
+
"log_count": len(logs),
|
|
568
|
+
"logs": logs,
|
|
569
|
+
}
|
|
570
|
+
except Exception:
|
|
571
|
+
pass # Fall through to mock logs
|
|
572
|
+
|
|
573
|
+
# Return informative mock logs for deployments without running servers
|
|
574
|
+
mock_logs = [
|
|
575
|
+
{
|
|
576
|
+
"timestamp": datetime.now().isoformat(),
|
|
577
|
+
"level": "INFO",
|
|
578
|
+
"message": f"Model Explorer session started for deployment: {deployment_id}",
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
"timestamp": datetime.now().isoformat(),
|
|
582
|
+
"level": "INFO",
|
|
583
|
+
"message": "No dedicated model server running - predictions use on-demand inference",
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
"timestamp": datetime.now().isoformat(),
|
|
587
|
+
"level": "INFO",
|
|
588
|
+
"message": "Tip: Deploy model to see live server logs",
|
|
589
|
+
},
|
|
590
|
+
]
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
"deployment_id": deployment_id,
|
|
594
|
+
"log_count": len(mock_logs),
|
|
595
|
+
"logs": mock_logs,
|
|
596
|
+
"mock": True,
|
|
597
|
+
}
|