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.
Files changed (126) hide show
  1. flowyml/assets/base.py +15 -0
  2. flowyml/assets/metrics.py +5 -0
  3. flowyml/cli/main.py +709 -0
  4. flowyml/cli/stack_cli.py +138 -25
  5. flowyml/core/__init__.py +17 -0
  6. flowyml/core/executor.py +161 -26
  7. flowyml/core/image_builder.py +129 -0
  8. flowyml/core/log_streamer.py +227 -0
  9. flowyml/core/orchestrator.py +22 -2
  10. flowyml/core/pipeline.py +34 -10
  11. flowyml/core/routing.py +558 -0
  12. flowyml/core/step.py +9 -1
  13. flowyml/core/step_grouping.py +49 -35
  14. flowyml/core/types.py +407 -0
  15. flowyml/monitoring/alerts.py +10 -0
  16. flowyml/monitoring/notifications.py +104 -25
  17. flowyml/monitoring/slack_blocks.py +323 -0
  18. flowyml/plugins/__init__.py +251 -0
  19. flowyml/plugins/alerters/__init__.py +1 -0
  20. flowyml/plugins/alerters/slack.py +168 -0
  21. flowyml/plugins/base.py +752 -0
  22. flowyml/plugins/config.py +478 -0
  23. flowyml/plugins/deployers/__init__.py +22 -0
  24. flowyml/plugins/deployers/gcp_cloud_run.py +200 -0
  25. flowyml/plugins/deployers/sagemaker.py +306 -0
  26. flowyml/plugins/deployers/vertex.py +290 -0
  27. flowyml/plugins/integration.py +369 -0
  28. flowyml/plugins/manager.py +510 -0
  29. flowyml/plugins/model_registries/__init__.py +22 -0
  30. flowyml/plugins/model_registries/mlflow.py +159 -0
  31. flowyml/plugins/model_registries/sagemaker.py +489 -0
  32. flowyml/plugins/model_registries/vertex.py +386 -0
  33. flowyml/plugins/orchestrators/__init__.py +13 -0
  34. flowyml/plugins/orchestrators/sagemaker.py +443 -0
  35. flowyml/plugins/orchestrators/vertex_ai.py +461 -0
  36. flowyml/plugins/registries/__init__.py +13 -0
  37. flowyml/plugins/registries/ecr.py +321 -0
  38. flowyml/plugins/registries/gcr.py +313 -0
  39. flowyml/plugins/registry.py +454 -0
  40. flowyml/plugins/stack.py +494 -0
  41. flowyml/plugins/stack_config.py +537 -0
  42. flowyml/plugins/stores/__init__.py +13 -0
  43. flowyml/plugins/stores/gcs.py +460 -0
  44. flowyml/plugins/stores/s3.py +453 -0
  45. flowyml/plugins/trackers/__init__.py +11 -0
  46. flowyml/plugins/trackers/mlflow.py +316 -0
  47. flowyml/plugins/validators/__init__.py +3 -0
  48. flowyml/plugins/validators/deepchecks.py +119 -0
  49. flowyml/registry/__init__.py +2 -1
  50. flowyml/registry/model_environment.py +109 -0
  51. flowyml/registry/model_registry.py +241 -96
  52. flowyml/serving/__init__.py +17 -0
  53. flowyml/serving/model_server.py +628 -0
  54. flowyml/stacks/__init__.py +60 -0
  55. flowyml/stacks/aws.py +93 -0
  56. flowyml/stacks/base.py +62 -0
  57. flowyml/stacks/components.py +12 -0
  58. flowyml/stacks/gcp.py +44 -9
  59. flowyml/stacks/plugins.py +115 -0
  60. flowyml/stacks/registry.py +2 -1
  61. flowyml/storage/sql.py +401 -12
  62. flowyml/tracking/experiment.py +8 -5
  63. flowyml/ui/backend/Dockerfile +87 -16
  64. flowyml/ui/backend/auth.py +12 -2
  65. flowyml/ui/backend/main.py +149 -5
  66. flowyml/ui/backend/routers/ai_context.py +226 -0
  67. flowyml/ui/backend/routers/assets.py +23 -4
  68. flowyml/ui/backend/routers/auth.py +96 -0
  69. flowyml/ui/backend/routers/deployments.py +660 -0
  70. flowyml/ui/backend/routers/model_explorer.py +597 -0
  71. flowyml/ui/backend/routers/plugins.py +103 -51
  72. flowyml/ui/backend/routers/projects.py +91 -8
  73. flowyml/ui/backend/routers/runs.py +20 -1
  74. flowyml/ui/backend/routers/schedules.py +22 -17
  75. flowyml/ui/backend/routers/templates.py +319 -0
  76. flowyml/ui/backend/routers/websocket.py +2 -2
  77. flowyml/ui/frontend/Dockerfile +55 -6
  78. flowyml/ui/frontend/dist/assets/index-B5AsPTSz.css +1 -0
  79. flowyml/ui/frontend/dist/assets/index-dFbZ8wD8.js +753 -0
  80. flowyml/ui/frontend/dist/index.html +2 -2
  81. flowyml/ui/frontend/dist/logo.png +0 -0
  82. flowyml/ui/frontend/nginx.conf +65 -4
  83. flowyml/ui/frontend/package-lock.json +1404 -74
  84. flowyml/ui/frontend/package.json +3 -0
  85. flowyml/ui/frontend/public/logo.png +0 -0
  86. flowyml/ui/frontend/src/App.jsx +10 -7
  87. flowyml/ui/frontend/src/app/auth/Login.jsx +90 -0
  88. flowyml/ui/frontend/src/app/dashboard/page.jsx +8 -8
  89. flowyml/ui/frontend/src/app/deployments/page.jsx +786 -0
  90. flowyml/ui/frontend/src/app/model-explorer/page.jsx +1031 -0
  91. flowyml/ui/frontend/src/app/pipelines/page.jsx +12 -2
  92. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +19 -6
  93. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +36 -24
  94. flowyml/ui/frontend/src/app/runs/page.jsx +8 -2
  95. flowyml/ui/frontend/src/app/settings/page.jsx +267 -253
  96. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +29 -7
  97. flowyml/ui/frontend/src/components/Layout.jsx +6 -0
  98. flowyml/ui/frontend/src/components/PipelineGraph.jsx +79 -29
  99. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +36 -6
  100. flowyml/ui/frontend/src/components/RunMetaPanel.jsx +113 -0
  101. flowyml/ui/frontend/src/components/ai/AIAssistantButton.jsx +71 -0
  102. flowyml/ui/frontend/src/components/ai/AIAssistantPanel.jsx +420 -0
  103. flowyml/ui/frontend/src/components/header/Header.jsx +22 -0
  104. flowyml/ui/frontend/src/components/plugins/PluginManager.jsx +4 -4
  105. flowyml/ui/frontend/src/components/plugins/{ZenMLIntegration.jsx → StackImport.jsx} +38 -12
  106. flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +36 -13
  107. flowyml/ui/frontend/src/contexts/AIAssistantContext.jsx +245 -0
  108. flowyml/ui/frontend/src/contexts/AuthContext.jsx +108 -0
  109. flowyml/ui/frontend/src/hooks/useAIContext.js +156 -0
  110. flowyml/ui/frontend/src/hooks/useWebGPU.js +54 -0
  111. flowyml/ui/frontend/src/layouts/MainLayout.jsx +6 -0
  112. flowyml/ui/frontend/src/router/index.jsx +47 -20
  113. flowyml/ui/frontend/src/services/pluginService.js +3 -1
  114. flowyml/ui/server_manager.py +5 -5
  115. flowyml/ui/utils.py +157 -39
  116. flowyml/utils/config.py +37 -15
  117. flowyml/utils/model_introspection.py +123 -0
  118. flowyml/utils/observability.py +30 -0
  119. flowyml-1.8.0.dist-info/METADATA +174 -0
  120. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/RECORD +123 -65
  121. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/WHEEL +1 -1
  122. flowyml/ui/frontend/dist/assets/index-B40RsQDq.css +0 -1
  123. flowyml/ui/frontend/dist/assets/index-CjI0zKCn.js +0 -685
  124. flowyml-1.7.2.dist-info/METADATA +0 -477
  125. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/entry_points.txt +0 -0
  126. {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
+ }