abstractnote 0.1.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.
- abstractnote/__init__.py +18 -0
- abstractnote/cli.py +240 -0
- abstractnote/flows/__init__.py +2 -0
- abstractnote/flows/build.py +970 -0
- abstractnote/flows/bundle.py +62 -0
- abstractnote/flows/contract.py +88 -0
- abstractnote/flows/graph.py +540 -0
- abstractnote/flows/json/note-capture.json +2280 -0
- abstractnote/flows/json/note-drain.json +2012 -0
- abstractnote/flows/json/note-mine.json +2392 -0
- abstractnote/flows/json/note-process.json +1853 -0
- abstractnote/flows/json/note-recall.json +1876 -0
- abstractnote/flows/json/note-reflect.json +2057 -0
- abstractnote/flows/prompts_fallback.py +119 -0
- abstractnote/flows/runner.py +320 -0
- abstractnote/flows/schemas_fallback.py +175 -0
- abstractnote/gateway/__init__.py +24 -0
- abstractnote/gateway/appspec.py +10 -0
- abstractnote/gateway/router.py +571 -0
- abstractnote/gateway/tools.py +74 -0
- abstractnote/ids.py +177 -0
- abstractnote/importers/__init__.py +142 -0
- abstractnote/importers/__main__.py +3 -0
- abstractnote/importers/agora.py +203 -0
- abstractnote/importers/claude_sessions.py +415 -0
- abstractnote/importers/cli.py +113 -0
- abstractnote/importers/files.py +72 -0
- abstractnote/importers/markdown.py +165 -0
- abstractnote/ingest/__init__.py +7 -0
- abstractnote/ingest/blobs.py +47 -0
- abstractnote/ingest/capture.py +265 -0
- abstractnote/ingest/extract.py +118 -0
- abstractnote/ingest/normalise.py +95 -0
- abstractnote/model.py +229 -0
- abstractnote/process/__init__.py +5 -0
- abstractnote/process/context.py +218 -0
- abstractnote/process/merge.py +60 -0
- abstractnote/process/prompts/__init__.py +39 -0
- abstractnote/process/prompts/extract.md +30 -0
- abstractnote/process/prompts/link.md +32 -0
- abstractnote/process/prompts/mine_dossier.md +13 -0
- abstractnote/process/prompts/mine_findings.md +13 -0
- abstractnote/process/prompts/mine_plan.md +13 -0
- abstractnote/process/prompts/quick_filing.md +23 -0
- abstractnote/process/prompts/reflect.md +10 -0
- abstractnote/process/prompts/synthesize.md +17 -0
- abstractnote/process/resolve.py +96 -0
- abstractnote/process/revise.py +410 -0
- abstractnote/process/schemas.py +377 -0
- abstractnote/process/vocabulary.py +68 -0
- abstractnote/recall/__init__.py +1 -0
- abstractnote/recall/changes.py +191 -0
- abstractnote/recall/expand.py +379 -0
- abstractnote/recall/insights.py +62 -0
- abstractnote/recall/reflect.py +202 -0
- abstractnote/recall/review.py +191 -0
- abstractnote/recall/search.py +205 -0
- abstractnote/recall/shapes.py +52 -0
- abstractnote/recall/then_now.py +100 -0
- abstractnote/store/__init__.py +12 -0
- abstractnote/store/archive.py +166 -0
- abstractnote/store/embeddings.py +275 -0
- abstractnote/store/kg.py +219 -0
- abstractnote/store/notebook.py +829 -0
- abstractnote/store/notes.py +1190 -0
- abstractnote/store/queue.py +159 -0
- abstractnote/store/schema.py +348 -0
- abstractnote/store/vector_index.py +88 -0
- abstractnote/tools/__init__.py +215 -0
- abstractnote/tools/capture.py +121 -0
- abstractnote/tools/context.py +19 -0
- abstractnote/tools/process.py +22 -0
- abstractnote/tools/queue.py +23 -0
- abstractnote/tools/recall.py +64 -0
- abstractnote-0.1.0.dist-info/METADATA +145 -0
- abstractnote-0.1.0.dist-info/RECORD +80 -0
- abstractnote-0.1.0.dist-info/WHEEL +5 -0
- abstractnote-0.1.0.dist-info/entry_points.txt +2 -0
- abstractnote-0.1.0.dist-info/licenses/LICENSE +21 -0
- abstractnote-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"""A small VisualFlow graph builder for the AbstractNote flow generator.
|
|
2
|
+
|
|
3
|
+
Modelled on AbstractFlow's ``scripts/wf_common.py`` (node/edge shapes, catalog pins,
|
|
4
|
+
the anti-blob ``set_vars`` idiom, per-field subflow pins) but self-contained, because
|
|
5
|
+
AbstractFlow is not a Python package and ``abstractnote bundle`` must run anywhere the
|
|
6
|
+
package is installed. Every node type used here is a catalog type
|
|
7
|
+
(abstractflow/docs/workflow-node-catalog.md); the compiler refuses anything else.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
15
|
+
|
|
16
|
+
Pin = Tuple[str, str] # (pin id, pin type)
|
|
17
|
+
Src = Tuple[str, str] # (node id, output pin id)
|
|
18
|
+
|
|
19
|
+
EXEC_IN = {"id": "exec-in", "label": "", "type": "execution"}
|
|
20
|
+
EXEC_OUT = {"id": "exec-out", "label": "", "type": "execution"}
|
|
21
|
+
|
|
22
|
+
# Pins the executor rewrites on an exec-lane code node's output record (see
|
|
23
|
+
# wf_common._EXEC_RESERVED_OUTPUT_PINS): a named output pin may never use them.
|
|
24
|
+
EXEC_RESERVED_OUTPUT_PINS = frozenset({"success", "output", "result", "error", "execution"})
|
|
25
|
+
|
|
26
|
+
# Box model shared with AbstractFlow's canvas and audit_flow_graph.py.
|
|
27
|
+
NODE_WIDTH = 320.0
|
|
28
|
+
NODE_CHROME_H = 90.0
|
|
29
|
+
PIN_ROW_H = 26.0
|
|
30
|
+
MIN_HEIGHT = 220.0
|
|
31
|
+
COLUMN_GAP_X = 160.0
|
|
32
|
+
NODE_GAP_Y = 100.0
|
|
33
|
+
|
|
34
|
+
_PLACEHOLDER = re.compile(r"\{\{\s*(.*?)\s*\}\}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pin(pin_id: str, pin_type: str, label: Optional[str] = None) -> Dict[str, Any]:
|
|
38
|
+
return {"id": pin_id, "label": pin_id if label is None else label, "type": pin_type}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def template_variables(template: str) -> set[str]:
|
|
42
|
+
"""Root variable names a ``{{var.path | filter}}`` template reads."""
|
|
43
|
+
out: set[str] = set()
|
|
44
|
+
for expr in _PLACEHOLDER.findall(template or ""):
|
|
45
|
+
root = expr.split("|", 1)[0].strip().split(".", 1)[0].strip()
|
|
46
|
+
if root:
|
|
47
|
+
out.add(root)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Flow:
|
|
52
|
+
"""One VisualFlow document under construction."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, flow_id: str, name: str, description: str, *, interfaces: Sequence[str] = ()) -> None:
|
|
55
|
+
self.doc: Dict[str, Any] = {
|
|
56
|
+
"id": flow_id,
|
|
57
|
+
"name": name,
|
|
58
|
+
"description": description,
|
|
59
|
+
"interfaces": list(interfaces),
|
|
60
|
+
"nodes": [],
|
|
61
|
+
"edges": [],
|
|
62
|
+
"entryNode": "start",
|
|
63
|
+
}
|
|
64
|
+
self._ids: set[str] = set()
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------ raw
|
|
67
|
+
|
|
68
|
+
def node(
|
|
69
|
+
self,
|
|
70
|
+
node_id: str,
|
|
71
|
+
node_type: str,
|
|
72
|
+
label: str,
|
|
73
|
+
*,
|
|
74
|
+
inputs: Iterable[Dict[str, Any]] = (),
|
|
75
|
+
outputs: Iterable[Dict[str, Any]] = (),
|
|
76
|
+
pin_defaults: Optional[Dict[str, Any]] = None,
|
|
77
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
78
|
+
) -> str:
|
|
79
|
+
if node_id in self._ids:
|
|
80
|
+
raise ValueError(f"{self.doc['id']}: duplicate node id {node_id!r}")
|
|
81
|
+
self._ids.add(node_id)
|
|
82
|
+
data: Dict[str, Any] = {
|
|
83
|
+
"nodeType": node_type,
|
|
84
|
+
"label": label,
|
|
85
|
+
"inputs": list(inputs),
|
|
86
|
+
"outputs": list(outputs),
|
|
87
|
+
}
|
|
88
|
+
if pin_defaults:
|
|
89
|
+
data["pinDefaults"] = pin_defaults
|
|
90
|
+
if extra:
|
|
91
|
+
data.update(extra)
|
|
92
|
+
self.doc["nodes"].append({"id": node_id, "type": node_type, "position": {"x": 0, "y": 0}, "data": data})
|
|
93
|
+
return node_id
|
|
94
|
+
|
|
95
|
+
def edge(self, source: str, source_handle: str, target: str, target_handle: str) -> None:
|
|
96
|
+
eid = f"e-{source}-{source_handle}-{target}-{target_handle}".replace(":", "-")
|
|
97
|
+
self.doc["edges"].append(
|
|
98
|
+
{"id": eid, "source": source, "sourceHandle": source_handle, "target": target, "targetHandle": target_handle}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def then(self, source: str, target: str, handle: str = "exec-out") -> None:
|
|
102
|
+
self.edge(source, handle, target, "exec-in")
|
|
103
|
+
|
|
104
|
+
def wire(self, src: Src, target: str, target_pin: str) -> None:
|
|
105
|
+
self.edge(src[0], src[1], target, target_pin)
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------ catalog
|
|
108
|
+
|
|
109
|
+
def start(self, pins: Sequence[Pin], defaults: Dict[str, Any]) -> str:
|
|
110
|
+
return self.node(
|
|
111
|
+
"start", "on_flow_start", "Start",
|
|
112
|
+
outputs=[EXEC_OUT, *(pin(p, t) for p, t in pins)],
|
|
113
|
+
pin_defaults=dict(defaults),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def end(self, node_id: str, label: str, values: Dict[str, Tuple[str, Any]]) -> str:
|
|
117
|
+
"""on_flow_end; `values` = {pin: (type, Src or literal)}. Each pin becomes run.output[pin]."""
|
|
118
|
+
inputs = [EXEC_IN]
|
|
119
|
+
literals: Dict[str, Any] = {}
|
|
120
|
+
wires: List[Tuple[str, Src]] = []
|
|
121
|
+
for pid, (ptype, value) in values.items():
|
|
122
|
+
inputs.append(pin(pid, ptype))
|
|
123
|
+
if isinstance(value, tuple):
|
|
124
|
+
wires.append((pid, value))
|
|
125
|
+
else:
|
|
126
|
+
literals[pid] = value
|
|
127
|
+
nid = self.node(node_id, "on_flow_end", label, inputs=inputs, pin_defaults=literals or None)
|
|
128
|
+
for pid, src in wires:
|
|
129
|
+
self.wire(src, nid, pid)
|
|
130
|
+
return nid
|
|
131
|
+
|
|
132
|
+
def code(
|
|
133
|
+
self,
|
|
134
|
+
node_id: str,
|
|
135
|
+
label: str,
|
|
136
|
+
body: str,
|
|
137
|
+
*,
|
|
138
|
+
inputs: Dict[str, Tuple[str, Any]],
|
|
139
|
+
outputs: Sequence[Pin],
|
|
140
|
+
exec_lane: bool = True,
|
|
141
|
+
) -> str:
|
|
142
|
+
"""A sandboxed code node. `inputs` = {pin: (type, Src or literal default)}.
|
|
143
|
+
|
|
144
|
+
Named outputs map the returned dict's keys onto pins. On the exec lane the node runs
|
|
145
|
+
once, in order; the reserved record keys are refused as pin names.
|
|
146
|
+
"""
|
|
147
|
+
if exec_lane:
|
|
148
|
+
clash = sorted(p for p, _ in outputs if p in EXEC_RESERVED_OUTPUT_PINS)
|
|
149
|
+
if clash:
|
|
150
|
+
raise ValueError(f"{self.doc['id']}.{node_id}: output pins {clash} are reserved on exec code nodes")
|
|
151
|
+
defaults: Dict[str, Any] = {"permissions": "sandbox"}
|
|
152
|
+
wires: List[Tuple[str, Src]] = []
|
|
153
|
+
in_pins = [EXEC_IN] if exec_lane else []
|
|
154
|
+
for pid, (ptype, value) in inputs.items():
|
|
155
|
+
in_pins.append(pin(pid, ptype))
|
|
156
|
+
if isinstance(value, tuple):
|
|
157
|
+
wires.append((pid, value))
|
|
158
|
+
else:
|
|
159
|
+
defaults[pid] = value
|
|
160
|
+
in_pins.append(pin("permissions", "string"))
|
|
161
|
+
out_pins = ([EXEC_OUT] if exec_lane else []) + [pin(p, t) for p, t in outputs]
|
|
162
|
+
nid = self.node(
|
|
163
|
+
node_id, "code", label, inputs=in_pins, outputs=out_pins, pin_defaults=defaults,
|
|
164
|
+
extra={"functionName": "transform", "codeBody": body.strip() + "\n"},
|
|
165
|
+
)
|
|
166
|
+
for pid, src in wires:
|
|
167
|
+
self.wire(src, nid, pid)
|
|
168
|
+
return nid
|
|
169
|
+
|
|
170
|
+
def tool_calls(self, node_id: str, label: str, tool_name: str, calls: Src) -> str:
|
|
171
|
+
nid = self.node(
|
|
172
|
+
node_id, "tool_calls", label,
|
|
173
|
+
inputs=[EXEC_IN, pin("tool_calls", "array"), pin("allowed_tools", "array")],
|
|
174
|
+
outputs=[EXEC_OUT, pin("results", "array"), pin("success", "boolean")],
|
|
175
|
+
pin_defaults={"allowed_tools": [tool_name]},
|
|
176
|
+
extra={"effectConfig": {"allowed_tools": [tool_name]}},
|
|
177
|
+
)
|
|
178
|
+
self.wire(calls, nid, "tool_calls")
|
|
179
|
+
return nid
|
|
180
|
+
|
|
181
|
+
def loop_tool_step(
|
|
182
|
+
self,
|
|
183
|
+
step_id: str,
|
|
184
|
+
label: str,
|
|
185
|
+
tool_name: str,
|
|
186
|
+
args: Dict[str, Tuple[str, Any]],
|
|
187
|
+
outputs: Sequence[Pin],
|
|
188
|
+
after: Tuple[str, str],
|
|
189
|
+
) -> Tuple[str, Tuple[str, str]]:
|
|
190
|
+
"""A tool step INSIDE a loop body (while / ForEach): it never raises.
|
|
191
|
+
|
|
192
|
+
Inside a control block the runtime turns a node exception into "branch complete" and runs
|
|
193
|
+
the next iteration (compiler ``wrapped``: ``plan.complete_output`` -> return to the active
|
|
194
|
+
control node), so a raising result node would make a drain spin until the while guard. Here
|
|
195
|
+
the result node reports ``failed`` / ``failure`` instead, an ``if`` routes a failure to its
|
|
196
|
+
own ``on_flow_end`` (an end node terminates the run even inside a loop) with
|
|
197
|
+
``{success: false, error, response}`` = the tool's sentence. Returns ``(out node id, the
|
|
198
|
+
(node, handle) the loop body continues from)``.
|
|
199
|
+
"""
|
|
200
|
+
out_id = self._tool_nodes(step_id, label, tool_name, args, outputs, after, raising=False)
|
|
201
|
+
check = self.if_(f"{step_id}_failed", f"{label}: failed?", (out_id, "failed"))
|
|
202
|
+
self.then(out_id, check)
|
|
203
|
+
self.end(f"{step_id}_stopped", f"Stopped: {label.lower()} failed", {
|
|
204
|
+
"response": ("string", (out_id, "failure")), "error": ("string", (out_id, "failure")),
|
|
205
|
+
"success": ("boolean", False),
|
|
206
|
+
})
|
|
207
|
+
self.then(check, f"{step_id}_stopped", "true")
|
|
208
|
+
return out_id, (check, "false")
|
|
209
|
+
|
|
210
|
+
def tool_step(
|
|
211
|
+
self,
|
|
212
|
+
step_id: str,
|
|
213
|
+
label: str,
|
|
214
|
+
tool_name: str,
|
|
215
|
+
args: Dict[str, Tuple[str, Any]],
|
|
216
|
+
outputs: Sequence[Pin],
|
|
217
|
+
after: Tuple[str, str],
|
|
218
|
+
) -> str:
|
|
219
|
+
"""One deterministic note tool call, visible as three nodes:
|
|
220
|
+
|
|
221
|
+
``<id>_args`` (code: one input pin per tool argument -> the {name, arguments} call; an
|
|
222
|
+
unset (None) or blank ("") value is left out so the tool's own default applies: the
|
|
223
|
+
runtime type-checks arguments before the call and refuses "" for an integer),
|
|
224
|
+
``<id>`` (tool_calls, allowlist = this one tool), ``<id>_out`` (code: the tool's
|
|
225
|
+
result object spread onto named pins, plus ``tool_output`` = the whole object; a
|
|
226
|
+
failed call raises its own plain sentence and ends the run with that error).
|
|
227
|
+
Returns the ``_out`` node id; the exec chain continues from its ``exec-out``.
|
|
228
|
+
Never use it inside a loop body: use ``loop_tool_step``.
|
|
229
|
+
"""
|
|
230
|
+
return self._tool_nodes(step_id, label, tool_name, args, outputs, after, raising=True)
|
|
231
|
+
|
|
232
|
+
def _tool_nodes(self, step_id: str, label: str, tool_name: str, args: Dict[str, Tuple[str, Any]],
|
|
233
|
+
outputs: Sequence[Pin], after: Tuple[str, str], *, raising: bool) -> str:
|
|
234
|
+
arg_names = list(args)
|
|
235
|
+
lines = ["arguments = {}"]
|
|
236
|
+
for a in arg_names:
|
|
237
|
+
ident = a
|
|
238
|
+
lines.append(f"if {ident} is not None and {ident} != \"\":")
|
|
239
|
+
lines.append(f" arguments[{json.dumps(a)}] = {ident}")
|
|
240
|
+
lines.append(
|
|
241
|
+
"return {\"tool_calls\": [{\"name\": " + json.dumps(tool_name)
|
|
242
|
+
+ ", \"call_id\": " + json.dumps(step_id) + ", \"arguments\": arguments}]}"
|
|
243
|
+
)
|
|
244
|
+
args_id = self.code(
|
|
245
|
+
f"{step_id}_args", f"{label}: arguments", "\n".join(lines),
|
|
246
|
+
inputs=args, outputs=[("tool_calls", "array")],
|
|
247
|
+
)
|
|
248
|
+
self.then(after[0], args_id, after[1])
|
|
249
|
+
call_id = self.tool_calls(step_id, label, tool_name, (args_id, "tool_calls"))
|
|
250
|
+
self.then(args_id, call_id)
|
|
251
|
+
out_body = (
|
|
252
|
+
"r = results[0] if isinstance(results, list) and results else None\n"
|
|
253
|
+
"failure = None\n"
|
|
254
|
+
"out = None\n"
|
|
255
|
+
"if not isinstance(r, dict):\n"
|
|
256
|
+
f" failure = {json.dumps(tool_name + ' returned no result.')}\n"
|
|
257
|
+
"elif not r.get(\"success\"):\n"
|
|
258
|
+
" failure = str(r.get(\"error\") or " + json.dumps(tool_name + " failed.") + ")\n"
|
|
259
|
+
"else:\n"
|
|
260
|
+
" out = r.get(\"output\")\n"
|
|
261
|
+
" if not isinstance(out, dict):\n"
|
|
262
|
+
f" failure = {json.dumps(tool_name + ' returned something other than an object.')}\n"
|
|
263
|
+
)
|
|
264
|
+
if raising:
|
|
265
|
+
out_body += (
|
|
266
|
+
"if failure is not None:\n"
|
|
267
|
+
" raise ValueError(failure)\n"
|
|
268
|
+
"return dict(out, tool_output=out)\n"
|
|
269
|
+
)
|
|
270
|
+
extra_out: List[Pin] = [("tool_output", "object")]
|
|
271
|
+
else:
|
|
272
|
+
out_body += (
|
|
273
|
+
"if failure is not None:\n"
|
|
274
|
+
" return {\"failed\": True, \"failure\": failure}\n"
|
|
275
|
+
"return dict(out, tool_output=out, failed=False, failure=None)\n"
|
|
276
|
+
)
|
|
277
|
+
extra_out = [("tool_output", "object"), ("failed", "boolean"), ("failure", "string")]
|
|
278
|
+
out_id = self.code(
|
|
279
|
+
f"{step_id}_out", f"{label}: result", out_body,
|
|
280
|
+
inputs={"results": ("array", (call_id, "results"))},
|
|
281
|
+
outputs=[*outputs, *extra_out],
|
|
282
|
+
)
|
|
283
|
+
self.then(call_id, out_id)
|
|
284
|
+
return out_id
|
|
285
|
+
|
|
286
|
+
def template(self, node_id: str, label: str, template: str, variables: Dict[str, Src]) -> str:
|
|
287
|
+
used = template_variables(template)
|
|
288
|
+
unknown = sorted(used - set(variables))
|
|
289
|
+
if unknown:
|
|
290
|
+
raise ValueError(
|
|
291
|
+
f"{self.doc['id']}.{node_id}: the prompt uses {unknown}, which this flow does not provide "
|
|
292
|
+
f"(it provides {sorted(variables)})"
|
|
293
|
+
)
|
|
294
|
+
nid = self.node(
|
|
295
|
+
node_id, "string_template", label,
|
|
296
|
+
inputs=[pin("template", "string"), *(pin(v, "any") for v in variables)],
|
|
297
|
+
outputs=[pin("result", "string")],
|
|
298
|
+
pin_defaults={"template": template},
|
|
299
|
+
)
|
|
300
|
+
for v, src in variables.items():
|
|
301
|
+
self.wire(src, nid, v)
|
|
302
|
+
return nid
|
|
303
|
+
|
|
304
|
+
def llm(self, node_id: str, label: str, *, schema: Dict[str, Any], prompt: Src, provider: Src, model: Src,
|
|
305
|
+
system: str, temperature: float = 0.2) -> str:
|
|
306
|
+
nid = self.node(
|
|
307
|
+
node_id, "llm_call", label,
|
|
308
|
+
inputs=[
|
|
309
|
+
EXEC_IN, pin("provider", "provider_text"), pin("model", "model"), pin("system", "string"),
|
|
310
|
+
pin("prompt", "string"), pin("tools", "tools"), pin("temperature", "number"),
|
|
311
|
+
pin("resp_schema", "json_schema"),
|
|
312
|
+
],
|
|
313
|
+
outputs=[
|
|
314
|
+
EXEC_OUT, pin("response", "string"), pin("data", "object"), pin("success", "boolean"),
|
|
315
|
+
pin("meta", "object"), pin("tool_calls", "array"),
|
|
316
|
+
],
|
|
317
|
+
# tools = [] as a DECLARED default so no host-injected tool list reaches a judgement call.
|
|
318
|
+
pin_defaults={"tools": [], "system": system, "temperature": temperature, "resp_schema": schema},
|
|
319
|
+
)
|
|
320
|
+
self.wire(prompt, nid, "prompt")
|
|
321
|
+
self.wire(provider, nid, "provider")
|
|
322
|
+
self.wire(model, nid, "model")
|
|
323
|
+
return nid
|
|
324
|
+
|
|
325
|
+
def break_object(self, node_id: str, label: str, obj: Src, fields: Sequence[Pin]) -> str:
|
|
326
|
+
nid = self.node(
|
|
327
|
+
node_id, "break_object", label,
|
|
328
|
+
inputs=[pin("object", "object")],
|
|
329
|
+
outputs=[pin(f, t) for f, t in fields],
|
|
330
|
+
extra={"breakConfig": {"selectedPaths": [f for f, _ in fields]}},
|
|
331
|
+
)
|
|
332
|
+
self.wire(obj, nid, "object")
|
|
333
|
+
return nid
|
|
334
|
+
|
|
335
|
+
def answer_user(self, node_id: str, label: str, message: Src) -> str:
|
|
336
|
+
nid = self.node(
|
|
337
|
+
node_id, "answer_user", label,
|
|
338
|
+
inputs=[EXEC_IN, pin("message", "string"), pin("level", "string")],
|
|
339
|
+
outputs=[EXEC_OUT, pin("message", "string")],
|
|
340
|
+
pin_defaults={"level": "message"},
|
|
341
|
+
)
|
|
342
|
+
self.wire(message, nid, "message")
|
|
343
|
+
return nid
|
|
344
|
+
|
|
345
|
+
def if_(self, node_id: str, label: str, condition: Src) -> str:
|
|
346
|
+
nid = self.node(
|
|
347
|
+
node_id, "if", label,
|
|
348
|
+
inputs=[EXEC_IN, pin("condition", "boolean")],
|
|
349
|
+
outputs=[pin("true", "execution"), pin("false", "execution")],
|
|
350
|
+
)
|
|
351
|
+
self.wire(condition, nid, "condition")
|
|
352
|
+
return nid
|
|
353
|
+
|
|
354
|
+
def switch(self, node_id: str, label: str, value: Src, cases: Sequence[str]) -> str:
|
|
355
|
+
nid = self.node(
|
|
356
|
+
node_id, "switch", label,
|
|
357
|
+
inputs=[EXEC_IN, pin("value", "string")],
|
|
358
|
+
outputs=[*(pin(f"case:{c}", "execution", c) for c in cases), pin("default", "execution")],
|
|
359
|
+
extra={"switchConfig": {"cases": [{"id": c, "value": c} for c in cases]}},
|
|
360
|
+
)
|
|
361
|
+
self.wire(value, nid, "value")
|
|
362
|
+
return nid
|
|
363
|
+
|
|
364
|
+
def subflow(self, node_id: str, label: str, flow_id: str, inputs: Dict[str, Tuple[str, Src]],
|
|
365
|
+
outputs: Sequence[Pin]) -> str:
|
|
366
|
+
nid = self.node(
|
|
367
|
+
node_id, "subflow", label,
|
|
368
|
+
inputs=[EXEC_IN, pin("inherit_context", "boolean"), *(pin(p, t) for p, (t, _) in inputs.items())],
|
|
369
|
+
outputs=[EXEC_OUT, pin("output", "object"), pin("child_output", "object"),
|
|
370
|
+
*(pin(p, t) for p, t in outputs)],
|
|
371
|
+
pin_defaults={"inherit_context": False},
|
|
372
|
+
extra={"subflowId": flow_id},
|
|
373
|
+
)
|
|
374
|
+
for p, (_, src) in inputs.items():
|
|
375
|
+
self.wire(src, nid, p)
|
|
376
|
+
return nid
|
|
377
|
+
|
|
378
|
+
def child_check(self, node_id: str, label: str, subflow_id: str) -> str:
|
|
379
|
+
"""After a subflow (NOT inside a loop body): end the run with the child's own sentence when
|
|
380
|
+
the child failed (a child that dies or whose node raised returns ``success: false``)."""
|
|
381
|
+
nid = self.code(
|
|
382
|
+
node_id, label,
|
|
383
|
+
"o = output if isinstance(output, dict) else {}\n"
|
|
384
|
+
"c = child_output if isinstance(child_output, dict) else {}\n"
|
|
385
|
+
"if c.get(\"success\") is False or o.get(\"success\") is False:\n"
|
|
386
|
+
" err = str(c.get(\"error\") or o.get(\"error\") or \"The step could not finish.\")\n"
|
|
387
|
+
" for prefix in (\"Runtime error: \", \"Execution error: \"):\n"
|
|
388
|
+
" if err.startswith(prefix):\n"
|
|
389
|
+
" err = err[len(prefix):]\n"
|
|
390
|
+
" raise ValueError(err)\n"
|
|
391
|
+
"return {\"checked\": True}",
|
|
392
|
+
inputs={"output": ("object", (subflow_id, "output")), "child_output": ("object", (subflow_id, "child_output"))},
|
|
393
|
+
outputs=[("checked", "boolean")],
|
|
394
|
+
)
|
|
395
|
+
self.then(subflow_id, nid)
|
|
396
|
+
return nid
|
|
397
|
+
|
|
398
|
+
def get_var(self, node_id: str, name: str, default: Any) -> str:
|
|
399
|
+
return self.node(
|
|
400
|
+
node_id, "get_var", f"Get {name}",
|
|
401
|
+
inputs=[pin("name", "string"), pin("default", "any")],
|
|
402
|
+
outputs=[pin("value", "any")],
|
|
403
|
+
pin_defaults={"name": name, "default": default},
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def set_var(self, node_id: str, label: str, name: str, value: Any) -> str:
|
|
407
|
+
return self.node(
|
|
408
|
+
node_id, "set_var", label,
|
|
409
|
+
inputs=[EXEC_IN, pin("name", "string"), pin("value", "any")],
|
|
410
|
+
outputs=[EXEC_OUT, pin("value", "any")],
|
|
411
|
+
pin_defaults={"name": name, "value": value},
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def set_vars(self, node_id: str, label: str, updates: Src) -> str:
|
|
415
|
+
nid = self.node(
|
|
416
|
+
node_id, "set_vars", label,
|
|
417
|
+
inputs=[EXEC_IN, pin("updates", "object")],
|
|
418
|
+
outputs=[EXEC_OUT, pin("updates", "object")],
|
|
419
|
+
)
|
|
420
|
+
self.wire(updates, nid, "updates")
|
|
421
|
+
return nid
|
|
422
|
+
|
|
423
|
+
def add_to_list(self, node_id: str, label: str, name: str, item: Src) -> str:
|
|
424
|
+
nid = self.node(
|
|
425
|
+
node_id, "add_to_list", label,
|
|
426
|
+
inputs=[EXEC_IN, pin("name", "string"), pin("item", "any"), pin("items", "array")],
|
|
427
|
+
outputs=[EXEC_OUT, pin("list", "array")],
|
|
428
|
+
pin_defaults={"name": name},
|
|
429
|
+
)
|
|
430
|
+
self.wire(item, nid, "item")
|
|
431
|
+
return nid
|
|
432
|
+
|
|
433
|
+
def while_(self, node_id: str, label: str, condition: Src) -> str:
|
|
434
|
+
nid = self.node(
|
|
435
|
+
node_id, "while", label,
|
|
436
|
+
inputs=[EXEC_IN, pin("condition", "boolean")],
|
|
437
|
+
outputs=[pin("loop", "execution"), pin("done", "execution"), pin("index", "number"), pin("item", "any")],
|
|
438
|
+
)
|
|
439
|
+
self.wire(condition, nid, "condition")
|
|
440
|
+
return nid
|
|
441
|
+
|
|
442
|
+
def for_each(self, node_id: str, label: str, items: Src) -> str:
|
|
443
|
+
nid = self.node(
|
|
444
|
+
node_id, "loop", label,
|
|
445
|
+
inputs=[EXEC_IN, pin("items", "array")],
|
|
446
|
+
outputs=[pin("loop", "execution"), pin("done", "execution"), pin("item", "any"), pin("index", "number")],
|
|
447
|
+
)
|
|
448
|
+
self.wire(items, nid, "items")
|
|
449
|
+
return nid
|
|
450
|
+
|
|
451
|
+
# ------------------------------------------------------------- finish
|
|
452
|
+
|
|
453
|
+
def finish(self, *, automation_defaults: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
454
|
+
if automation_defaults is not None:
|
|
455
|
+
self.doc["automation_defaults"] = automation_defaults
|
|
456
|
+
self._check_edges()
|
|
457
|
+
layout(self.doc)
|
|
458
|
+
return self.doc
|
|
459
|
+
|
|
460
|
+
def _check_edges(self) -> None:
|
|
461
|
+
pins: Dict[str, Tuple[set, set, str]] = {}
|
|
462
|
+
for n in self.doc["nodes"]:
|
|
463
|
+
d = n["data"]
|
|
464
|
+
pins[n["id"]] = ({p["id"] for p in d["inputs"]}, {p["id"] for p in d["outputs"]}, d["nodeType"])
|
|
465
|
+
problems = []
|
|
466
|
+
seen_data_targets: set = set()
|
|
467
|
+
for e in self.doc["edges"]:
|
|
468
|
+
s, t = e["source"], e["target"]
|
|
469
|
+
if s not in pins or t not in pins:
|
|
470
|
+
problems.append(f"edge {e['id']}: unknown node")
|
|
471
|
+
continue
|
|
472
|
+
if e["sourceHandle"] not in pins[s][1] and not (pins[s][2] == "subflow" and e["sourceHandle"] == "child_output"):
|
|
473
|
+
problems.append(f"edge {e['id']}: {s} has no output pin {e['sourceHandle']}")
|
|
474
|
+
if e["targetHandle"] not in pins[t][0]:
|
|
475
|
+
problems.append(f"edge {e['id']}: {t} has no input pin {e['targetHandle']}")
|
|
476
|
+
if e["targetHandle"] != "exec-in":
|
|
477
|
+
key = (t, e["targetHandle"])
|
|
478
|
+
if key in seen_data_targets:
|
|
479
|
+
problems.append(f"edge {e['id']}: {t}.{e['targetHandle']} has two data wires")
|
|
480
|
+
seen_data_targets.add(key)
|
|
481
|
+
if problems:
|
|
482
|
+
raise ValueError(f"{self.doc['id']}: " + "; ".join(problems))
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _node_height(node: Dict[str, Any]) -> float:
|
|
486
|
+
d = node["data"]
|
|
487
|
+
rows = max(
|
|
488
|
+
len([p for p in d.get("inputs") or [] if p.get("type") != "execution"]),
|
|
489
|
+
len([p for p in d.get("outputs") or [] if p.get("type") != "execution"]),
|
|
490
|
+
)
|
|
491
|
+
return max(NODE_CHROME_H + PIN_ROW_H * rows, MIN_HEIGHT)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _is_exec_edge(edge: Dict[str, Any], exec_targets: Dict[str, set]) -> bool:
|
|
495
|
+
return edge["targetHandle"] == "exec-in"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def layout(doc: Dict[str, Any]) -> None:
|
|
499
|
+
"""Left-to-right columns by exec depth; pure nodes one column left of their first consumer.
|
|
500
|
+
|
|
501
|
+
Each column is stacked top-down with the canvas box model, so no two boxes overlap.
|
|
502
|
+
"""
|
|
503
|
+
nodes = {n["id"]: n for n in doc["nodes"]}
|
|
504
|
+
exec_children: Dict[str, List[str]] = {}
|
|
505
|
+
data_consumers: Dict[str, List[str]] = {}
|
|
506
|
+
for e in doc["edges"]:
|
|
507
|
+
if e["targetHandle"] == "exec-in":
|
|
508
|
+
exec_children.setdefault(e["source"], []).append(e["target"])
|
|
509
|
+
else:
|
|
510
|
+
data_consumers.setdefault(e["source"], []).append(e["target"])
|
|
511
|
+
depth: Dict[str, int] = {"start": 0} if "start" in nodes else {}
|
|
512
|
+
queue = list(depth)
|
|
513
|
+
while queue:
|
|
514
|
+
cur = queue.pop(0)
|
|
515
|
+
for child in exec_children.get(cur, []):
|
|
516
|
+
if child not in depth:
|
|
517
|
+
depth[child] = depth[cur] + 1
|
|
518
|
+
queue.append(child)
|
|
519
|
+
# Pure nodes: left of their earliest-placed consumer (iterate until stable).
|
|
520
|
+
for _ in range(len(nodes)):
|
|
521
|
+
changed = False
|
|
522
|
+
for nid in nodes:
|
|
523
|
+
if nid in depth:
|
|
524
|
+
continue
|
|
525
|
+
placed = [depth[c] for c in data_consumers.get(nid, []) if c in depth]
|
|
526
|
+
if placed:
|
|
527
|
+
depth[nid] = max(min(placed) - 1, 0)
|
|
528
|
+
changed = True
|
|
529
|
+
if not changed:
|
|
530
|
+
break
|
|
531
|
+
for nid in nodes:
|
|
532
|
+
depth.setdefault(nid, 0)
|
|
533
|
+
columns: Dict[int, List[str]] = {}
|
|
534
|
+
for nid in [n["id"] for n in doc["nodes"]]:
|
|
535
|
+
columns.setdefault(depth[nid], []).append(nid)
|
|
536
|
+
for col, ids in columns.items():
|
|
537
|
+
y = 0.0
|
|
538
|
+
for nid in ids:
|
|
539
|
+
nodes[nid]["position"] = {"x": col * (NODE_WIDTH + COLUMN_GAP_X), "y": y}
|
|
540
|
+
y += _node_height(nodes[nid]) + NODE_GAP_Y
|