m8flow 1.0.2 → 1.1.1

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.
@@ -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() if code.strip() else None
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 arrive fully formed.
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
- return {
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(3).fillna("").astype(str).to_dict(orient="records"),
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(3).fillna("").astype(str).to_dict(orient="records"),
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):
@@ -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, prefix="/api/flows", tags=["Flows"])
53
- app.include_router(nodes.router, prefix="/api/nodes", tags=["Nodes"])
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
- return {"status": "ok"}
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
- scikit-learn
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
- scipy
33
+
34
+ # ── Legacy / compat ───────────────────────────────────────────────────────────
13
35
  openai
14
- python-multipart