m8flow 1.0.2 → 1.1.0
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.
- package/bundled/backend/Dockerfile +41 -0
- package/bundled/backend/add_nodes.py +416 -0
- package/bundled/backend/api/routes/appstate.py +102 -0
- package/bundled/backend/api/routes/flows.py +64 -5
- package/bundled/backend/api/routes/nodes.py +25 -1
- package/bundled/backend/core/code_validator.py +2 -0
- package/bundled/backend/core/executor.py +19 -3
- package/bundled/backend/main.py +16 -4
- package/bundled/backend/requirements.txt +27 -6
- package/bundled/backend/services/llm_service.py +957 -98
- package/bundled/backend/services/self_healer.py +1 -1
- package/bundled/backend/temp.json +0 -0
- package/bundled/backend/templates.json +0 -0
- package/bundled/backend/templates.py +2907 -745
- package/bundled/backend/warmup.py +65 -0
- package/bundled/frontend-dist/assets/index-CKUZ27n8.css +1 -0
- package/bundled/frontend-dist/assets/index-DNaB6zf0.js +46 -0
- package/bundled/frontend-dist/index.html +2 -2
- package/lib/backend.js +155 -35
- package/lib/run.js +18 -7
- package/lib/setup.js +119 -59
- package/package.json +3 -2
- package/scripts/check-docker.js +35 -0
- package/bundled/frontend-dist/assets/index-BAQ3lKsy.css +0 -1
- package/bundled/frontend-dist/assets/index-CZCCzeUC.js +0 -41
|
@@ -13,12 +13,14 @@ router = APIRouter()
|
|
|
13
13
|
class GenerateRequest(BaseModel):
|
|
14
14
|
prompt: str
|
|
15
15
|
context: str | None = None
|
|
16
|
+
custom_components: list[dict] | None = None
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class UpdateRequest(BaseModel):
|
|
19
20
|
prompt: str
|
|
20
21
|
current_flow: FlowSchema
|
|
21
22
|
context: str | None = None
|
|
23
|
+
custom_components: list[dict] | None = None
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
class AskRequest(BaseModel):
|
|
@@ -26,6 +28,7 @@ class AskRequest(BaseModel):
|
|
|
26
28
|
prompt: str
|
|
27
29
|
current_flow: FlowSchema | None = None
|
|
28
30
|
context: str | None = None
|
|
31
|
+
custom_components: list[dict] | None = None
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
def _resolve_template_id(node: dict) -> str:
|
|
@@ -56,7 +59,7 @@ def _to_canvas_node(node: dict) -> dict:
|
|
|
56
59
|
code = ""
|
|
57
60
|
label = node_data.get("label") or template_id
|
|
58
61
|
|
|
59
|
-
schema = parse_node_code(code).model_dump()
|
|
62
|
+
schema = parse_node_code(code).model_dump()
|
|
60
63
|
|
|
61
64
|
# Merge schema defaults with explicit values from LLM
|
|
62
65
|
defaults: dict = {}
|
|
@@ -94,7 +97,7 @@ async def generate_flow(http_request: Request, req: GenerateRequest):
|
|
|
94
97
|
if not req.prompt.strip():
|
|
95
98
|
raise HTTPException(status_code=422, detail="Prompt cannot be empty")
|
|
96
99
|
try:
|
|
97
|
-
flow = await llm_service.generate_flow(req.prompt, context=req.context)
|
|
100
|
+
flow = await llm_service.generate_flow(req.prompt, context=req.context, custom_components=req.custom_components)
|
|
98
101
|
except ValueError as exc:
|
|
99
102
|
raise HTTPException(status_code=422, detail=str(exc))
|
|
100
103
|
except RuntimeError as exc:
|
|
@@ -111,7 +114,7 @@ async def update_flow(http_request: Request, req: UpdateRequest):
|
|
|
111
114
|
if not req.prompt.strip():
|
|
112
115
|
raise HTTPException(status_code=422, detail="Prompt cannot be empty")
|
|
113
116
|
try:
|
|
114
|
-
flow = await llm_service.update_flow(req.prompt, req.current_flow, context=req.context)
|
|
117
|
+
flow = await llm_service.update_flow(req.prompt, req.current_flow, context=req.context, custom_components=req.custom_components)
|
|
115
118
|
except ValueError as exc:
|
|
116
119
|
raise HTTPException(status_code=422, detail=str(exc))
|
|
117
120
|
except RuntimeError as exc:
|
|
@@ -129,19 +132,39 @@ async def refine_flow(http_request: Request, req: UpdateRequest):
|
|
|
129
132
|
if not req.prompt.strip():
|
|
130
133
|
raise HTTPException(status_code=422, detail="Prompt cannot be empty")
|
|
131
134
|
try:
|
|
132
|
-
patch = await llm_service.refine_flow(req.prompt, req.current_flow, context=req.context)
|
|
135
|
+
patch = await llm_service.refine_flow(req.prompt, req.current_flow, context=req.context, custom_components=req.custom_components)
|
|
133
136
|
except ValueError as exc:
|
|
134
137
|
raise HTTPException(status_code=422, detail=str(exc))
|
|
135
138
|
except RuntimeError as exc:
|
|
136
139
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
137
140
|
|
|
138
|
-
# Expand any "add" nodes through _to_canvas_node so they
|
|
141
|
+
# Expand any "add" or type-swap "update" nodes through _to_canvas_node so they
|
|
142
|
+
# arrive fully formed (correct templateId, code, schema, label).
|
|
139
143
|
enriched_nodes: list[dict] = []
|
|
140
144
|
for nc in patch.node_changes:
|
|
141
145
|
d = nc.model_dump()
|
|
142
146
|
if nc.action == "add":
|
|
143
147
|
raw_node = {"id": nc.id, "type": nc.type or "", "position": nc.position or {"x": 0, "y": 200}, "data": nc.data or {}}
|
|
144
148
|
d["node"] = _to_canvas_node(raw_node)
|
|
149
|
+
elif nc.action == "update":
|
|
150
|
+
# Template swap: LLM might put the new type at the root (nc.type) or inside data
|
|
151
|
+
new_type = nc.type
|
|
152
|
+
if not new_type and isinstance(nc.data, dict):
|
|
153
|
+
new_type = nc.data.get("type")
|
|
154
|
+
|
|
155
|
+
if new_type:
|
|
156
|
+
existing = next(
|
|
157
|
+
(n for n in req.current_flow.nodes if n.get("id") == nc.id), {}
|
|
158
|
+
)
|
|
159
|
+
raw_node = {
|
|
160
|
+
"id": nc.id,
|
|
161
|
+
"type": new_type,
|
|
162
|
+
"position": existing.get("position", {"x": 0, "y": 200}),
|
|
163
|
+
"data": {"values": (nc.data or {}).get("values", {})},
|
|
164
|
+
}
|
|
165
|
+
d["node"] = _to_canvas_node(raw_node)
|
|
166
|
+
# Ensure the new type is passed back to the frontend inside node.data so the store applyPatch picks it up correctly if nc.node is missing for some reason
|
|
167
|
+
d["type"] = new_type
|
|
145
168
|
enriched_nodes.append(d)
|
|
146
169
|
|
|
147
170
|
return {
|
|
@@ -170,6 +193,7 @@ async def ask_flow(http_request: Request, req: AskRequest):
|
|
|
170
193
|
prompt=req.prompt,
|
|
171
194
|
current_flow=req.current_flow,
|
|
172
195
|
context=req.context,
|
|
196
|
+
custom_components=req.custom_components,
|
|
173
197
|
)
|
|
174
198
|
except ValueError as exc:
|
|
175
199
|
raise HTTPException(status_code=422, detail=str(exc))
|
|
@@ -202,6 +226,26 @@ async def ask_flow(http_request: Request, req: AskRequest):
|
|
|
202
226
|
"data": nc.data or {},
|
|
203
227
|
}
|
|
204
228
|
d["node"] = _to_canvas_node(raw_node)
|
|
229
|
+
elif nc.action == "update":
|
|
230
|
+
# Template swap: LLM might put the new type at the root (nc.type) or inside data
|
|
231
|
+
new_type = nc.type
|
|
232
|
+
if not new_type and isinstance(nc.data, dict):
|
|
233
|
+
new_type = nc.data.get("type")
|
|
234
|
+
|
|
235
|
+
if new_type:
|
|
236
|
+
existing = next(
|
|
237
|
+
(n for n in (req.current_flow.nodes if req.current_flow else [])
|
|
238
|
+
if n.get("id") == nc.id),
|
|
239
|
+
{},
|
|
240
|
+
)
|
|
241
|
+
raw_node = {
|
|
242
|
+
"id": nc.id,
|
|
243
|
+
"type": new_type,
|
|
244
|
+
"position": existing.get("position", {"x": 0, "y": 200}),
|
|
245
|
+
"data": {"values": (nc.data or {}).get("values", {})},
|
|
246
|
+
}
|
|
247
|
+
d["node"] = _to_canvas_node(raw_node)
|
|
248
|
+
d["type"] = new_type
|
|
205
249
|
enriched_nodes.append(d)
|
|
206
250
|
|
|
207
251
|
return {
|
|
@@ -382,6 +426,21 @@ async def explain_flow_route(http_request: Request, flow: FlowSchema):
|
|
|
382
426
|
raise HTTPException(status_code=500, detail=f"{type(exc).__name__}: {exc}")
|
|
383
427
|
|
|
384
428
|
|
|
429
|
+
class ChatExplanationRequest(BaseModel):
|
|
430
|
+
question: str
|
|
431
|
+
explanation: str
|
|
432
|
+
current_flow: FlowSchema
|
|
433
|
+
|
|
434
|
+
@router.post("/explain_chat")
|
|
435
|
+
async def explain_chat_route(http_request: Request, req: ChatExplanationRequest):
|
|
436
|
+
_inject_api_key(http_request)
|
|
437
|
+
try:
|
|
438
|
+
response = await llm_service.chat_explanation(req.question, req.explanation, req.current_flow)
|
|
439
|
+
return {"response": response}
|
|
440
|
+
except Exception as exc:
|
|
441
|
+
raise HTTPException(status_code=500, detail=f"{type(exc).__name__}: {exc}")
|
|
442
|
+
|
|
443
|
+
|
|
385
444
|
@router.post("/")
|
|
386
445
|
def create_flow(flow: FlowSchema):
|
|
387
446
|
flow_id = flow_service.create_flow(flow)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from fastapi import APIRouter, UploadFile, File
|
|
1
|
+
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
|
|
2
2
|
from pydantic import BaseModel
|
|
3
3
|
import os
|
|
4
4
|
import shutil
|
|
@@ -68,6 +68,30 @@ def download_file(path: str):
|
|
|
68
68
|
return FileResponse(path, filename=os.path.basename(path))
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
class GenerateCodeRequest(BaseModel):
|
|
72
|
+
description: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@router.post("/generate-code")
|
|
76
|
+
async def generate_node_code_route(http_request: Request, req: GenerateCodeRequest):
|
|
77
|
+
"""Generate M8Flow-compatible Python node code from a plain-English description."""
|
|
78
|
+
from services import llm_service
|
|
79
|
+
|
|
80
|
+
# Honour the per-request OpenRouter key set by the frontend
|
|
81
|
+
key = http_request.headers.get("X-OpenRouter-Key")
|
|
82
|
+
llm_service._request_api_key.set(key or None)
|
|
83
|
+
|
|
84
|
+
if not req.description.strip():
|
|
85
|
+
raise HTTPException(status_code=422, detail="Description cannot be empty")
|
|
86
|
+
try:
|
|
87
|
+
code = await llm_service.generate_node_code(req.description)
|
|
88
|
+
return {"code": code}
|
|
89
|
+
except RuntimeError as exc:
|
|
90
|
+
raise HTTPException(status_code=503, detail=str(exc))
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
raise HTTPException(status_code=500, detail=f"{type(exc).__name__}: {exc}")
|
|
93
|
+
|
|
94
|
+
|
|
71
95
|
@router.get("/templates")
|
|
72
96
|
def list_templates():
|
|
73
97
|
"""Return every prebuilt template with its pre-parsed schema attached."""
|
|
@@ -40,6 +40,8 @@ ALLOWED_IMPORTS = frozenset({
|
|
|
40
40
|
# Scientific computing
|
|
41
41
|
"numpy", "pandas", "scipy", "sklearn", "xgboost", "lightgbm",
|
|
42
42
|
"statsmodels", "imblearn",
|
|
43
|
+
# Plotting
|
|
44
|
+
"matplotlib", "seaborn", "plotly",
|
|
43
45
|
# Standard safe libs
|
|
44
46
|
"math", "statistics", "itertools", "functools", "collections",
|
|
45
47
|
"json", "re", "datetime", "typing",
|
|
@@ -24,15 +24,31 @@ def _serialize_value(val: Any) -> Any:
|
|
|
24
24
|
"""Recursively convert Python/pandas/numpy objects to JSON-safe dicts."""
|
|
25
25
|
if isinstance(val, pd.DataFrame):
|
|
26
26
|
null_counts = val.isnull().sum().to_dict()
|
|
27
|
-
|
|
27
|
+
result: dict = {
|
|
28
28
|
"_type": "DataFrame",
|
|
29
29
|
"shape": list(val.shape),
|
|
30
30
|
"columns": list(val.columns),
|
|
31
31
|
"dtypes": {c: str(t) for c, t in val.dtypes.items()},
|
|
32
|
-
"head": val.head(
|
|
32
|
+
"head": val.head(20).fillna("").astype(str).to_dict(orient="records"),
|
|
33
33
|
"null_counts": {k: int(v) for k, v in null_counts.items() if v > 0},
|
|
34
34
|
"null_total": int(val.isnull().sum().sum()),
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
# ── Auto-scatter for 2-column numeric DataFrames ───────────────────
|
|
38
|
+
# Lets ANY custom visualisation node (t-SNE, UMAP, PCA 2D, …) get a
|
|
39
|
+
# viz button automatically — without requiring custom frontend code.
|
|
40
|
+
num_cols = val.select_dtypes(include=[np.number]).columns.tolist()
|
|
41
|
+
if len(num_cols) >= 2:
|
|
42
|
+
# Sample up to 2 000 points so the chart stays responsive
|
|
43
|
+
sample = val[num_cols[:2]].dropna()
|
|
44
|
+
if len(sample) > 2_000:
|
|
45
|
+
sample = sample.sample(2_000, random_state=42)
|
|
46
|
+
result["x"] = [round(float(v), 5) for v in sample[num_cols[0]]]
|
|
47
|
+
result["y"] = [round(float(v), 5) for v in sample[num_cols[1]]]
|
|
48
|
+
result["x_label"] = num_cols[0]
|
|
49
|
+
result["y_label"] = num_cols[1]
|
|
50
|
+
|
|
51
|
+
return result
|
|
36
52
|
if isinstance(val, pd.Series):
|
|
37
53
|
return {
|
|
38
54
|
"_type": "Series",
|
|
@@ -77,7 +93,7 @@ def _build_preview(template_id: str, raw_outputs: dict[str, Any]) -> dict:
|
|
|
77
93
|
"columns": list(val.columns),
|
|
78
94
|
"null_total": int(val.isnull().sum().sum()),
|
|
79
95
|
"null_counts": {c: int(n) for c, n in val.isnull().sum().items() if n > 0},
|
|
80
|
-
"head": val.head(
|
|
96
|
+
"head": val.head(20).fillna("").astype(str).to_dict(orient="records"),
|
|
81
97
|
}
|
|
82
98
|
# Data-cleaning: capture before/after null comparison
|
|
83
99
|
if "df_before" in raw_outputs and isinstance(raw_outputs["df_before"], pd.DataFrame):
|
package/bundled/backend/main.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from fastapi import FastAPI, Request
|
|
2
2
|
from fastapi.middleware.cors import CORSMiddleware
|
|
3
3
|
from fastapi.responses import JSONResponse
|
|
4
|
-
from api.routes import flows, nodes
|
|
4
|
+
from api.routes import flows, nodes, appstate
|
|
5
|
+
import os
|
|
5
6
|
import time
|
|
6
7
|
import logging
|
|
7
8
|
|
|
@@ -9,6 +10,11 @@ import logging
|
|
|
9
10
|
logging.basicConfig(level=logging.INFO)
|
|
10
11
|
logger = logging.getLogger(__name__)
|
|
11
12
|
|
|
13
|
+
# Detect execution environment and log it at startup
|
|
14
|
+
_ENV = os.environ.get("M8FLOW_ENV", "venv")
|
|
15
|
+
_ENV_LABEL = "Containerized (Docker — Warm)" if _ENV == "docker" else "Python venv"
|
|
16
|
+
logger.info("[SYSTEM] Environment: %s", _ENV_LABEL)
|
|
17
|
+
|
|
12
18
|
app = FastAPI(title="M8Flow — Code-Driven ML Pipeline Builder")
|
|
13
19
|
|
|
14
20
|
# CORS configuration
|
|
@@ -49,11 +55,17 @@ async def add_process_time_header(request: Request, call_next):
|
|
|
49
55
|
logger.info(f"Path: {request.url.path} | Time: {process_time:.4f}s")
|
|
50
56
|
return response
|
|
51
57
|
|
|
52
|
-
app.include_router(flows.router,
|
|
53
|
-
app.include_router(nodes.router,
|
|
58
|
+
app.include_router(flows.router, prefix="/api/flows", tags=["Flows"])
|
|
59
|
+
app.include_router(nodes.router, prefix="/api/nodes", tags=["Nodes"])
|
|
60
|
+
app.include_router(appstate.router, prefix="/api/app/state", tags=["AppState"])
|
|
54
61
|
|
|
55
62
|
|
|
56
63
|
@app.get("/api/health")
|
|
57
64
|
def health_check():
|
|
58
|
-
|
|
65
|
+
from config import config
|
|
66
|
+
return {
|
|
67
|
+
"status": "ok",
|
|
68
|
+
"environment": _ENV_LABEL,
|
|
69
|
+
"server_key_configured": bool(config.OPENROUTER_API_KEY),
|
|
70
|
+
}
|
|
59
71
|
|
|
@@ -1,14 +1,35 @@
|
|
|
1
|
+
# ── Core API ─────────────────────────────────────────────────────────────────
|
|
1
2
|
fastapi
|
|
2
3
|
uvicorn[standard]
|
|
3
|
-
|
|
4
|
-
joblib
|
|
5
|
-
pandas
|
|
4
|
+
python-multipart
|
|
6
5
|
pydantic
|
|
7
6
|
python-dotenv
|
|
8
|
-
numpy
|
|
9
7
|
httpx
|
|
8
|
+
|
|
9
|
+
# ── Data ─────────────────────────────────────────────────────────────────────
|
|
10
|
+
pandas
|
|
11
|
+
numpy
|
|
12
|
+
scipy
|
|
13
|
+
joblib
|
|
14
|
+
|
|
15
|
+
# ── ML ───────────────────────────────────────────────────────────────────────
|
|
16
|
+
scikit-learn
|
|
17
|
+
xgboost
|
|
18
|
+
lightgbm
|
|
19
|
+
statsmodels
|
|
20
|
+
|
|
21
|
+
# ── Imbalanced learning ───────────────────────────────────────────────────────
|
|
22
|
+
imbalanced-learn
|
|
23
|
+
|
|
24
|
+
# ── Explainability ────────────────────────────────────────────────────────────
|
|
25
|
+
shap
|
|
26
|
+
|
|
27
|
+
# ── Dimensionality reduction ──────────────────────────────────────────────────
|
|
28
|
+
umap-learn
|
|
29
|
+
|
|
30
|
+
# ── Visualisation (server-side, not imported at runtime) ─────────────────────
|
|
10
31
|
matplotlib
|
|
11
32
|
seaborn
|
|
12
|
-
|
|
33
|
+
|
|
34
|
+
# ── Legacy / compat ───────────────────────────────────────────────────────────
|
|
13
35
|
openai
|
|
14
|
-
python-multipart
|