graphcore-studio 0.2.0__py3-none-win_amd64.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.
studio/server.py ADDED
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env python3
2
+ """Local-only Studio server. No third-party web framework or frontend build needed."""
3
+ import argparse
4
+ import copy
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from datetime import datetime, timezone
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ import importlib.util
9
+ import json
10
+ import mimetypes
11
+ import os
12
+ from pathlib import Path
13
+ import re
14
+ import secrets
15
+ import shlex
16
+ import tempfile
17
+ import sys
18
+ import threading
19
+ import time
20
+ import urllib.parse
21
+ import uuid
22
+
23
+ ROOT = Path(__file__).resolve().parents[1]
24
+ sys.path.insert(0, str(ROOT / "python"))
25
+ sys.path.insert(0, str(ROOT))
26
+ from studio.engine import compile_workflow, validate, TOOLS, pydantic_available, provider_availability, register_tool
27
+
28
+ WEB = ROOT / "studio" / "web"
29
+ TEMPLATES = ROOT / "studio" / "templates"
30
+ ID = re.compile(r"^[a-zA-Z0-9_-]{1,80}$")
31
+ TERMINAL = {"completed", "failed", "cancelled", "suspended", "recoverable"}
32
+ ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
33
+ ENV_BLOCKLIST = {"PATH", "PYTHONPATH", "PYTHONHOME", "HOME", "SHELL", "VIRTUAL_ENV",
34
+ "LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH"}
35
+
36
+ def now(): return datetime.now(timezone.utc).isoformat()
37
+
38
+ def write_json(path, value):
39
+ temp = path.with_suffix(path.suffix + ".tmp")
40
+ temp.write_text(json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False), encoding="utf-8")
41
+ temp.replace(path)
42
+
43
+ def read_dotenv(path):
44
+ values = {}
45
+ try: lines = Path(path).read_text(encoding="utf-8").splitlines()
46
+ except FileNotFoundError: return values
47
+ for line in lines:
48
+ line = line.strip()
49
+ if not line or line.startswith("#"): continue
50
+ if line.startswith("export "): line = line[7:].lstrip()
51
+ key, separator, value = line.partition("=")
52
+ key, value = key.strip(), value.strip()
53
+ if not separator or not ENV_NAME.fullmatch(key) or key in ENV_BLOCKLIST: continue
54
+ if value.startswith('"'):
55
+ try:
56
+ parsed, end = json.JSONDecoder().raw_decode(value)
57
+ if isinstance(parsed, str): value = parsed
58
+ except (ValueError, json.JSONDecodeError): pass
59
+ elif value.startswith("'") and value.endswith("'") and len(value) >= 2:
60
+ value = value[1:-1].replace("\\'", "'")
61
+ else:
62
+ value = re.split(r"\s+#", value, maxsplit=1)[0].rstrip()
63
+ values[key] = value
64
+ return values
65
+
66
+ def load_dotenv(path, override=False):
67
+ """Load Studio's local .env file before model/provider construction."""
68
+ values = read_dotenv(path)
69
+ for key, value in values.items():
70
+ if override or key not in os.environ: os.environ[key] = value
71
+ return values
72
+
73
+ def write_dotenv(path, values):
74
+ path = Path(path)
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ body = "# GraphCore Studio local model integrations\n" + "".join(
77
+ key + "=" + json.dumps(value, ensure_ascii=False) + "\n" for key, value in sorted(values.items()))
78
+ fd, temporary = tempfile.mkstemp(prefix=".env-", dir=path.parent)
79
+ try:
80
+ if hasattr(os, "fchmod"): os.fchmod(fd, 0o600)
81
+ with os.fdopen(fd, "w", encoding="utf-8") as output: output.write(body)
82
+ os.replace(temporary, path)
83
+ try: os.chmod(path, 0o600)
84
+ except OSError: pass
85
+ finally:
86
+ try: os.unlink(temporary)
87
+ except FileNotFoundError: pass
88
+
89
+ class Store:
90
+ def __init__(self, root):
91
+ self.root = Path(root)
92
+ self.root.mkdir(parents=True, exist_ok=True)
93
+ self.env_path = self.root / ".env"
94
+ self.base_environment = dict(os.environ)
95
+ self.env_lock = threading.RLock()
96
+ self.saved_environment = load_dotenv(self.env_path)
97
+ self.workflows = self.root / "workflows"; self.workflows.mkdir(exist_ok=True)
98
+ self.runs = self.root / "runs"; self.runs.mkdir(exist_ok=True)
99
+ self.lock = threading.RLock()
100
+ self.records = {}
101
+ self.active = {}
102
+ self.pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="graphcore")
103
+ self.slots = threading.BoundedSemaphore(4)
104
+ for path in sorted(self.runs.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:100]:
105
+ try:
106
+ record = json.loads(path.read_text())
107
+ if record["status"] in ("running", "queued", "cancelling"):
108
+ record["status"] = "recoverable" if self.checkpoint(record["id"]).exists() else "failed"
109
+ record["error"] = "Server stopped during execution. Recover from the last committed checkpoint."
110
+ if self.checkpoint(record["id"]).exists():
111
+ try:
112
+ from graphcore import Graph
113
+ with Graph() as reader:
114
+ saved = reader.inspect_checkpoint(self.checkpoint(record["id"]))
115
+ record["state"] = saved.state
116
+ if saved.suspended:
117
+ record.update(status="suspended", prompt=saved.prompt, error="")
118
+ except Exception:
119
+ pass
120
+ self.records[record["id"]] = record
121
+ except (ValueError, KeyError, OSError): pass
122
+
123
+ def environment_status(self):
124
+ stored = set(self.saved_environment)
125
+ names = sorted(stored | {name for provider in provider_availability() for name in provider.get("env", [])})
126
+ return [{"name": name, "configured": name in os.environ, "stored": name in stored}
127
+ for name in names]
128
+
129
+ def update_environment(self, values, remove):
130
+ if not isinstance(values, dict) or not isinstance(remove, list):
131
+ raise ValueError("Environment values must be an object and removals a list")
132
+ if len(values) + len(remove) > 100: raise ValueError("Configure at most 100 environment variables")
133
+ cleaned = {}
134
+ for key, value in values.items():
135
+ if not isinstance(key, str) or not ENV_NAME.fullmatch(key) or key in ENV_BLOCKLIST:
136
+ raise ValueError("Invalid or reserved environment variable name")
137
+ if not isinstance(value, str) or len(value) > 10000 or "\x00" in value or "\n" in value or "\r" in value:
138
+ raise ValueError("Environment values must be single-line text up to 10000 characters")
139
+ cleaned[key] = value
140
+ if any(not isinstance(key, str) or not ENV_NAME.fullmatch(key) or key in ENV_BLOCKLIST for key in remove):
141
+ raise ValueError("Invalid or reserved environment variable name to remove")
142
+ with self.env_lock:
143
+ result = dict(self.saved_environment)
144
+ for key in remove:
145
+ result.pop(key, None)
146
+ if key in self.base_environment: os.environ[key] = self.base_environment[key]
147
+ else: os.environ.pop(key, None)
148
+ result.update(cleaned)
149
+ write_dotenv(self.env_path, result)
150
+ self.saved_environment = result
151
+ os.environ.update(cleaned)
152
+ return self.environment_status()
153
+
154
+ def checkpoint(self, ident): return self.runs / (ident + ".checkpoint")
155
+
156
+ def snapshot(self, ident):
157
+ with self.lock:
158
+ if ident not in self.records: raise ValueError("Run does not exist")
159
+ return copy.deepcopy(self.records[ident])
160
+
161
+ def history(self):
162
+ with self.lock:
163
+ return [{k: copy.deepcopy(r.get(k)) for k in ("id", "name", "status", "created", "duration_ms", "error")}
164
+ for r in sorted(self.records.values(), key=lambda r: r["created"], reverse=True)[:100]]
165
+
166
+ def save_record(self, ident):
167
+ write_json(self.runs / (ident + ".json"), self.records[ident])
168
+
169
+ def start(self, workflow, inputs, max_steps=100):
170
+ errors = validate(workflow)
171
+ if errors: raise ValueError("\n".join(errors))
172
+ if not isinstance(inputs, dict): raise ValueError("Inputs must be a JSON object")
173
+ if not isinstance(max_steps, int) or not 1 <= max_steps <= 1000: raise ValueError("Step limit must be 1–1000")
174
+ if not self.slots.acquire(blocking=False): raise ValueError("Four runs are already active; wait for one to finish")
175
+ ident = uuid.uuid4().hex
176
+ record = {"id": ident, "name": workflow.get("name", "Untitled workflow"), "workflow": copy.deepcopy(workflow),
177
+ "inputs": copy.deepcopy(inputs), "max_steps": max_steps, "created": now(), "status": "queued",
178
+ "events": [], "state": {}, "prompt": "", "error": "", "duration_ms": 0}
179
+ try:
180
+ with self.lock:
181
+ self.records[ident] = record
182
+ self.save_record(ident)
183
+ self.pool.submit(self.execute, ident, "invoke", None)
184
+ except BaseException:
185
+ self.slots.release()
186
+ raise
187
+ return ident
188
+
189
+ def continue_run(self, ident, action, response=None):
190
+ with self.lock:
191
+ record = self.records.get(ident)
192
+ if not record: raise ValueError("Run does not exist")
193
+ if action == "resume" and record["status"] != "suspended": raise ValueError("Run is not awaiting approval")
194
+ if action == "recover" and record["status"] not in ("recoverable", "failed", "cancelled"):
195
+ raise ValueError("Run is not recoverable")
196
+ if not self.checkpoint(ident).exists(): raise ValueError("No checkpoint exists for this run")
197
+ if not self.slots.acquire(blocking=False): raise ValueError("Four runs are already active")
198
+ record["status"] = "queued"; record["error"] = ""
199
+ self.save_record(ident)
200
+ self.pool.submit(self.execute, ident, action, response)
201
+
202
+ def cancel(self, ident):
203
+ with self.lock:
204
+ record = self.records.get(ident)
205
+ if not record or record["status"] not in ("queued", "running", "cancelling"):
206
+ raise ValueError("Run is not active")
207
+ record["status"] = "cancelling"
208
+ if ident in self.active: self.active[ident].cancel()
209
+ self.save_record(ident)
210
+
211
+ def execute(self, ident, action, response):
212
+ start = time.monotonic()
213
+ graph = None
214
+ def emit(kind, **data):
215
+ with self.lock:
216
+ record = self.records[ident]
217
+ record["events"].append({"sequence": len(record["events"]) + 1, "type": kind, "time": now(), **data})
218
+ # Metadata follows a committed native checkpoint, never leads it.
219
+ if kind in ("checkpoint.committed", "run.interrupted"):
220
+ self.save_record(ident)
221
+ try:
222
+ with self.lock:
223
+ record = self.records[ident]
224
+ workflow, inputs, budget = record["workflow"], record["inputs"], record["max_steps"]
225
+ graph = compile_workflow(workflow, emit)
226
+ with self.lock:
227
+ self.active[ident] = graph
228
+ if record["status"] == "cancelling": graph.cancel()
229
+ else: record["status"] = "running"
230
+ if action == "resume": result = graph.resume(str(self.checkpoint(ident)), response=response, max_steps=budget)
231
+ elif action == "recover": result = graph.recover(str(self.checkpoint(ident)), max_steps=budget)
232
+ else: result = graph.invoke(inputs, checkpoint=str(self.checkpoint(ident)), max_steps=budget)
233
+ with self.lock:
234
+ record.update(status="suspended" if result.suspended else "completed", state=result.state,
235
+ prompt=result.prompt, steps=result.steps)
236
+ except Exception as error:
237
+ with self.lock:
238
+ record = self.records[ident]
239
+ record["status"] = "cancelled" if record["status"] == "cancelling" else "failed"
240
+ record["error"] = str(error)
241
+ finally:
242
+ with self.lock:
243
+ self.active.pop(ident, None)
244
+ record["duration_ms"] += round((time.monotonic() - start) * 1000)
245
+ self.save_record(ident)
246
+ if graph: graph.close()
247
+ self.slots.release()
248
+
249
+ def close(self):
250
+ with self.lock:
251
+ for graph in self.active.values(): graph.cancel()
252
+ self.pool.shutdown(wait=True)
253
+
254
+ class Handler(BaseHTTPRequestHandler):
255
+ server_version = "GraphCoreStudio/0.2"
256
+ def log_message(self, format, *args): pass
257
+
258
+ def allowed_host(self):
259
+ return self.headers.get("Host") in ("127.0.0.1:" + str(self.server.server_port), "localhost:" + str(self.server.server_port))
260
+
261
+ def send(self, data, code=200, content_type="application/json; charset=utf-8"):
262
+ body = json.dumps(data, ensure_ascii=False, allow_nan=False).encode() if not isinstance(data, bytes) else data
263
+ self.send_response(code)
264
+ self.send_header("Content-Type", content_type)
265
+ self.send_header("Content-Length", str(len(body)))
266
+ self.send_header("Cache-Control", "no-store")
267
+ self.send_header("X-Content-Type-Options", "nosniff")
268
+ self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'")
269
+ self.end_headers()
270
+ try: self.wfile.write(body)
271
+ except (BrokenPipeError, ConnectionResetError): pass
272
+
273
+ def do_GET(self):
274
+ if not self.allowed_host(): return self.send({"error": "Invalid local Host"}, 403)
275
+ path = urllib.parse.urlsplit(self.path).path
276
+ try:
277
+ if path == "/api/bootstrap":
278
+ try:
279
+ from graphcore import Graph
280
+ with Graph(): pass
281
+ native, error = True, ""
282
+ except Exception as failure: native, error = False, str(failure)
283
+ templates = [json.loads(p.read_text()) for p in sorted(TEMPLATES.glob("*.json"))]
284
+ return self.send({"csrf": self.server.token, "native": native, "native_error": error,
285
+ "pydantic": pydantic_available(), "providers": provider_availability(), "templates": templates,
286
+ "tools": [{"name": k, "description": v["description"]} for k,v in TOOLS.items()]})
287
+ if path == "/api/settings/environment":
288
+ return self.send({"variables": self.server.store.environment_status()})
289
+ if path == "/api/workflows":
290
+ with self.server.store.lock:
291
+ items = [json.loads(p.read_text()) for p in self.server.store.workflows.glob("*.json")]
292
+ return self.send(items)
293
+ if path == "/api/runs": return self.send(self.server.store.history())
294
+ if path.startswith("/api/runs/"):
295
+ ident = path.rsplit("/", 1)[1]
296
+ if not ID.fullmatch(ident): raise ValueError("Invalid run ID")
297
+ return self.send(self.server.store.snapshot(ident))
298
+ files = {"/": "index.html", "/app.js": "app.js", "/style.css": "style.css"}
299
+ if path not in files: return self.send({"error": "Not found"}, 404)
300
+ file = WEB / files[path]
301
+ return self.send(file.read_bytes(), content_type=mimetypes.guess_type(file.name)[0] + "; charset=utf-8")
302
+ except (ValueError, OSError) as error: self.send({"error": str(error)}, 400)
303
+
304
+ def do_POST(self):
305
+ if not self.allowed_host(): return self.send({"error": "Invalid local Host"}, 403)
306
+ origin = self.headers.get("Origin")
307
+ allowed = ("http://127.0.0.1:" + str(self.server.server_port), "http://localhost:" + str(self.server.server_port))
308
+ if (origin is not None and origin not in allowed) or not secrets.compare_digest(self.headers.get("X-Studio-Token", ""), self.server.token):
309
+ return self.send({"error": "Request must originate from the local Studio"}, 403)
310
+ try:
311
+ size = int(self.headers.get("Content-Length", "0"))
312
+ if size <= 0 or size > 2 * 1024 * 1024: raise ValueError("Request body must be 1 byte–2 MiB")
313
+ body = json.loads(self.rfile.read(size), parse_constant=lambda value: (_ for _ in ()).throw(ValueError("Non-finite JSON number")))
314
+ if not isinstance(body, dict): raise ValueError("Request must be a JSON object")
315
+ path = urllib.parse.urlsplit(self.path).path
316
+ if path == "/api/validate": return self.send({"errors": validate(body.get("workflow"))})
317
+ if path == "/api/settings/environment":
318
+ return self.send({"variables": self.server.store.update_environment(
319
+ body.get("values", {}), body.get("remove", []))})
320
+ if path == "/api/workflows":
321
+ workflow = body.get("workflow")
322
+ if not isinstance(workflow, dict) or workflow.get("format") != "graphcore.studio.v1": raise ValueError("Invalid workflow document")
323
+ # Drafts may have incomplete connections; validate before running.
324
+ ident = workflow.get("id")
325
+ if not isinstance(ident, str) or not ID.fullmatch(ident): raise ValueError("Invalid workflow ID")
326
+ with self.server.store.lock: write_json(self.server.store.workflows / (ident + ".json"), workflow)
327
+ return self.send({"saved": True, "id": ident})
328
+ if path == "/api/runs":
329
+ ident = self.server.store.start(body.get("workflow"), body.get("inputs", {}), body.get("max_steps", 100))
330
+ return self.send({"id": ident}, 202)
331
+ match = re.fullmatch(r"/api/runs/([a-zA-Z0-9_-]+)/(?P<action>resume|recover|cancel)", path)
332
+ if match:
333
+ ident, action = match.group(1), match.group("action")
334
+ if action == "cancel": self.server.store.cancel(ident)
335
+ else: self.server.store.continue_run(ident, action, body.get("response"))
336
+ return self.send({"id": ident}, 202)
337
+ return self.send({"error": "Not found"}, 404)
338
+ except (ValueError, OSError, TypeError, KeyError) as error:
339
+ self.send({"error": str(error)}, 400)
340
+
341
+ class Server(ThreadingHTTPServer):
342
+ daemon_threads = True
343
+ def __init__(self, address, store):
344
+ super().__init__(address, Handler)
345
+ self.store, self.token = store, secrets.token_urlsafe(32)
346
+
347
+ def main():
348
+ parser = argparse.ArgumentParser(description="GraphCore Studio — local visual agent builder")
349
+ parser.add_argument("--port", type=int, default=8787)
350
+ parser.add_argument("--data", default=os.environ.get("GRAPHCORE_STUDIO_DATA", str(ROOT / "studio" / "data")))
351
+ parser.add_argument("--plugin", action="append", default=[], help="Trusted local Python module file registering tools")
352
+ args = parser.parse_args()
353
+ load_dotenv(Path(args.data) / ".env")
354
+ for i, filename in enumerate(args.plugin):
355
+ spec = importlib.util.spec_from_file_location("studio_plugin_" + str(i), filename)
356
+ module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
357
+ module.register(register_tool)
358
+ store = Store(args.data)
359
+ server = Server(("127.0.0.1", args.port), store)
360
+ print("GraphCore Studio: http://127.0.0.1:" + str(server.server_port), flush=True)
361
+ print("Local files: " + str(store.root), flush=True)
362
+ try: server.serve_forever()
363
+ except KeyboardInterrupt: pass
364
+ finally: server.server_close(); store.close()
365
+
366
+ if __name__ == "__main__": main()
@@ -0,0 +1,114 @@
1
+ {
2
+ "format": "graphcore.studio.v1",
3
+ "id": "research-studio",
4
+ "name": "Research & review",
5
+ "description": "A two-agent writing team, with a native condition and a human checkpoint.",
6
+ "inputs": {
7
+ "input": "How can small teams use local AI responsibly?",
8
+ "needs_review": true
9
+ },
10
+ "nodes": [
11
+ {
12
+ "id": "input",
13
+ "type": "input",
14
+ "label": "Start with a topic",
15
+ "x": 60,
16
+ "y": 180,
17
+ "config": {}
18
+ },
19
+ {
20
+ "id": "researcher",
21
+ "type": "agent",
22
+ "label": "Researcher",
23
+ "x": 350,
24
+ "y": 60,
25
+ "config": {
26
+ "provider": "demo",
27
+ "model": "llama3.2",
28
+ "system": "You are a researcher. Find clear, useful ideas.",
29
+ "prompt": "Research this topic: {{input}}",
30
+ "demo_response": "Research notes: {{input}}\n\n1. Start with a narrow, measurable task.\n2. Keep sensitive data local.\n3. Review important decisions with a person.",
31
+ "output_key": "research"
32
+ }
33
+ },
34
+ {
35
+ "id": "writer",
36
+ "type": "agent",
37
+ "label": "Writer",
38
+ "x": 650,
39
+ "y": 60,
40
+ "config": {
41
+ "provider": "demo",
42
+ "model": "llama3.2",
43
+ "system": "You are an editor. Turn research into a concise, practical brief.",
44
+ "prompt": "Write a brief using these notes:\n{{research}}",
45
+ "demo_response": "A practical guide\n\n{{research}}\n\nNext step: build a small pilot, evaluate its output, and expand only after review.",
46
+ "output_key": "draft"
47
+ }
48
+ },
49
+ {
50
+ "id": "review_gate",
51
+ "type": "condition",
52
+ "label": "Needs human review?",
53
+ "x": 950,
54
+ "y": 180,
55
+ "config": {
56
+ "field": "needs_review",
57
+ "expected": true
58
+ }
59
+ },
60
+ {
61
+ "id": "approval",
62
+ "type": "approval",
63
+ "label": "Human checkpoint",
64
+ "x": 650,
65
+ "y": 350,
66
+ "config": {
67
+ "prompt": "Review this draft before continuing:\n\n{{draft}}",
68
+ "output_key": "review_response"
69
+ }
70
+ },
71
+ {
72
+ "id": "output",
73
+ "type": "output",
74
+ "label": "Final brief",
75
+ "x": 350,
76
+ "y": 350,
77
+ "config": {
78
+ "template": "{{draft}}"
79
+ }
80
+ }
81
+ ],
82
+ "edges": [
83
+ {
84
+ "source": "input",
85
+ "target": "researcher",
86
+ "port": "next"
87
+ },
88
+ {
89
+ "source": "researcher",
90
+ "target": "writer",
91
+ "port": "next"
92
+ },
93
+ {
94
+ "source": "writer",
95
+ "target": "review_gate",
96
+ "port": "next"
97
+ },
98
+ {
99
+ "source": "review_gate",
100
+ "target": "approval",
101
+ "port": "true"
102
+ },
103
+ {
104
+ "source": "review_gate",
105
+ "target": "output",
106
+ "port": "false"
107
+ },
108
+ {
109
+ "source": "approval",
110
+ "target": "output",
111
+ "port": "next"
112
+ }
113
+ ]
114
+ }
@@ -0,0 +1,86 @@
1
+ {
2
+ "format": "graphcore.studio.v1",
3
+ "id": "structured-output",
4
+ "name": "Structured extraction",
5
+ "description": "Generate a JSON object, then validate its fields before producing output.",
6
+ "inputs": {
7
+ "input": "A visual workflow builder for local agents."
8
+ },
9
+ "nodes": [
10
+ {
11
+ "id": "input",
12
+ "type": "input",
13
+ "label": "Your source text",
14
+ "x": 50,
15
+ "y": 150,
16
+ "config": {}
17
+ },
18
+ {
19
+ "id": "extract",
20
+ "type": "agent",
21
+ "label": "Extract a record",
22
+ "x": 350,
23
+ "y": 150,
24
+ "config": {
25
+ "provider": "demo",
26
+ "model": "llama3.2",
27
+ "system": "Extract a JSON object with summary (string) and confidence (number). Return JSON only.",
28
+ "prompt": "Extract from: {{input}}",
29
+ "demo_response": "{\"summary\":\"A local visual workflow builder\",\"confidence\":0.95}",
30
+ "json_output": true,
31
+ "output_key": "record"
32
+ }
33
+ },
34
+ {
35
+ "id": "validate",
36
+ "type": "schema",
37
+ "label": "Validate structure",
38
+ "x": 650,
39
+ "y": 150,
40
+ "config": {
41
+ "input_field": "record",
42
+ "output_key": "validated",
43
+ "engine": "builtin",
44
+ "fields": [
45
+ {
46
+ "name": "summary",
47
+ "type": "string",
48
+ "required": true
49
+ },
50
+ {
51
+ "name": "confidence",
52
+ "type": "number",
53
+ "required": true
54
+ }
55
+ ]
56
+ }
57
+ },
58
+ {
59
+ "id": "output",
60
+ "type": "output",
61
+ "label": "Validated result",
62
+ "x": 950,
63
+ "y": 150,
64
+ "config": {
65
+ "template": "{{validated}}"
66
+ }
67
+ }
68
+ ],
69
+ "edges": [
70
+ {
71
+ "source": "input",
72
+ "target": "extract",
73
+ "port": "next"
74
+ },
75
+ {
76
+ "source": "extract",
77
+ "target": "validate",
78
+ "port": "next"
79
+ },
80
+ {
81
+ "source": "validate",
82
+ "target": "output",
83
+ "port": "next"
84
+ }
85
+ ]
86
+ }
@@ -0,0 +1,70 @@
1
+ {
2
+ "format": "graphcore.studio.v1",
3
+ "id": "python-tools",
4
+ "name": "Python tool chain",
5
+ "description": "Call registered Python functions and route their output through the native engine.",
6
+ "inputs": {
7
+ "input": "Local workflows make useful tools accessible to everyone."
8
+ },
9
+ "nodes": [
10
+ {
11
+ "id": "input",
12
+ "type": "input",
13
+ "label": "Start with text",
14
+ "x": 50,
15
+ "y": 150,
16
+ "config": {}
17
+ },
18
+ {
19
+ "id": "keywords",
20
+ "type": "tool",
21
+ "label": "Extract keywords",
22
+ "x": 350,
23
+ "y": 150,
24
+ "config": {
25
+ "tool": "keywords",
26
+ "input_field": "input",
27
+ "output_key": "keywords"
28
+ }
29
+ },
30
+ {
31
+ "id": "count",
32
+ "type": "tool",
33
+ "label": "Measure the text",
34
+ "x": 650,
35
+ "y": 150,
36
+ "config": {
37
+ "tool": "word_count",
38
+ "input_field": "input",
39
+ "output_key": "stats"
40
+ }
41
+ },
42
+ {
43
+ "id": "output",
44
+ "type": "output",
45
+ "label": "Results",
46
+ "x": 950,
47
+ "y": 150,
48
+ "config": {
49
+ "template": "Keywords: {{keywords}}\n\nText statistics: {{stats}}"
50
+ }
51
+ }
52
+ ],
53
+ "edges": [
54
+ {
55
+ "source": "input",
56
+ "target": "keywords",
57
+ "port": "next"
58
+ },
59
+ {
60
+ "source": "keywords",
61
+ "target": "count",
62
+ "port": "next"
63
+ },
64
+ {
65
+ "source": "count",
66
+ "target": "output",
67
+ "port": "next"
68
+ }
69
+ ]
70
+ }