generflow-core 0.2.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.
- generflow_core/__init__.py +3 -0
- generflow_core/actions/__init__.py +22 -0
- generflow_core/actions/dispatcher.py +223 -0
- generflow_core/adapters/__init__.py +11 -0
- generflow_core/adapters/llm.py +186 -0
- generflow_core/api/__init__.py +5 -0
- generflow_core/api/app.py +494 -0
- generflow_core/api/prompt.py +64 -0
- generflow_core/cli.py +241 -0
- generflow_core/databind/__init__.py +30 -0
- generflow_core/databind/config.py +183 -0
- generflow_core/databind/resolver.py +306 -0
- generflow_core/hitl/__init__.py +22 -0
- generflow_core/hitl/gates.py +165 -0
- generflow_core/interop/__init__.py +257 -0
- generflow_core/observability/__init__.py +208 -0
- generflow_core/py.typed +0 -0
- generflow_core/registry/__init__.py +4 -0
- generflow_core/registry/registry.py +194 -0
- generflow_core/replay/__init__.py +189 -0
- generflow_core/spec/__init__.py +21 -0
- generflow_core/spec/ast.py +61 -0
- generflow_core/spec/diff.py +177 -0
- generflow_core/spec/parser.py +332 -0
- generflow_core/spec/update.py +136 -0
- generflow_core-0.2.0.dist-info/METADATA +161 -0
- generflow_core-0.2.0.dist-info/RECORD +30 -0
- generflow_core-0.2.0.dist-info/WHEEL +5 -0
- generflow_core-0.2.0.dist-info/entry_points.txt +3 -0
- generflow_core-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""GF-Lang parser with partial-stream recovery.
|
|
2
|
+
|
|
3
|
+
Designed for streaming: feed lines one at a time via feed_line(), get
|
|
4
|
+
back the parsed nodes that completed. Lines that don't parse cleanly
|
|
5
|
+
are flagged as invalid but never crash the stream.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Iterator
|
|
11
|
+
|
|
12
|
+
from .ast import Assignment, Component, Literal, Ref
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_IDENT = re.compile(r"[a-zA-Z_][a-zA-Z0-9_\-]*")
|
|
16
|
+
_STRING_DQ = re.compile(r'"((?:[^"\\]|\\.)*)"')
|
|
17
|
+
_STRING_SQ = re.compile(r"'((?:[^'\\]|\\.)*)'")
|
|
18
|
+
_NUMBER = re.compile(r"-?\d+(?:\.\d+)?")
|
|
19
|
+
_BOOL = re.compile(r"(true|false)\b")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ParseError(Exception):
|
|
23
|
+
"""Raised when a complete line cannot be parsed."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GFLangParser:
|
|
27
|
+
"""Incremental, partial-recovery parser for GF-Lang.
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
parser = GFLangParser()
|
|
31
|
+
for event in stream:
|
|
32
|
+
nodes = parser.feed(event.line)
|
|
33
|
+
for node in nodes:
|
|
34
|
+
render(node)
|
|
35
|
+
leftover = parser.flush() # anything left in buffer
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._buf = ""
|
|
40
|
+
|
|
41
|
+
def feed_line(self, line: str) -> list[Assignment | Component]:
|
|
42
|
+
"""DEPRECATED: use feed_chunk."""
|
|
43
|
+
return self.feed_chunk(line + "\n")
|
|
44
|
+
|
|
45
|
+
def feed_chunk(self, chunk: str) -> list[Assignment | Component]:
|
|
46
|
+
"""Feed a raw chunk (may contain partial lines, partial expressions).
|
|
47
|
+
|
|
48
|
+
State machine:
|
|
49
|
+
- Buffer accumulates text until the parser can extract a complete
|
|
50
|
+
top-level node (a balanced `Component(...)` or `root = Component(...)`).
|
|
51
|
+
- We retry parsing the buffer in a loop; if a complete node is
|
|
52
|
+
found, it's emitted and removed from the buffer.
|
|
53
|
+
- If the buffer is mid-construct (open parens/brackets), we hold it
|
|
54
|
+
and wait for more input.
|
|
55
|
+
- If the buffer starts with non-identifier garbage, drop everything
|
|
56
|
+
up to the next newline so a bad prefix doesn't block us.
|
|
57
|
+
"""
|
|
58
|
+
out: list[Assignment | Component] = []
|
|
59
|
+
self._buf += chunk
|
|
60
|
+
# safety counter to prevent infinite loops
|
|
61
|
+
max_iter = 1000
|
|
62
|
+
for _ in range(max_iter):
|
|
63
|
+
node, consumed = self._try_parse_top(self._buf)
|
|
64
|
+
if node is None:
|
|
65
|
+
break
|
|
66
|
+
self._buf = self._buf[consumed:]
|
|
67
|
+
out.append(node)
|
|
68
|
+
# partial-recovery loop: drop garbage prefixes and try again
|
|
69
|
+
for _ in range(10):
|
|
70
|
+
if not self._buf or self._is_mid_construct(self._buf):
|
|
71
|
+
break
|
|
72
|
+
before = self._buf
|
|
73
|
+
self._drop_garbage_line()
|
|
74
|
+
if self._buf == before:
|
|
75
|
+
break
|
|
76
|
+
# try parse again after drop
|
|
77
|
+
for _ in range(max_iter):
|
|
78
|
+
node, consumed = self._try_parse_top(self._buf)
|
|
79
|
+
if node is None:
|
|
80
|
+
break
|
|
81
|
+
self._buf = self._buf[consumed:]
|
|
82
|
+
out.append(node)
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
def _is_mid_construct(self, s: str) -> bool:
|
|
86
|
+
"""True if the buffer has unbalanced parens/brackets (more opens than closes)."""
|
|
87
|
+
parens = s.count("(") - s.count(")")
|
|
88
|
+
brackets = s.count("[") - s.count("]")
|
|
89
|
+
return parens > 0 or brackets > 0
|
|
90
|
+
|
|
91
|
+
def _drop_garbage_line(self) -> None:
|
|
92
|
+
"""If the buffer doesn't start with a valid start, drop the first line."""
|
|
93
|
+
s = self._buf.lstrip()
|
|
94
|
+
if not s:
|
|
95
|
+
return
|
|
96
|
+
# valid start: an alpha/underscore character (identifier) — could be
|
|
97
|
+
# a component, an assignment, or an Update( expression
|
|
98
|
+
if s[0].isalpha() or s[0] == "_":
|
|
99
|
+
return
|
|
100
|
+
# garbage: drop everything up to and including the first newline
|
|
101
|
+
if "\n" in self._buf:
|
|
102
|
+
self._buf = self._buf.split("\n", 1)[1]
|
|
103
|
+
else:
|
|
104
|
+
self._buf = ""
|
|
105
|
+
|
|
106
|
+
def flush(self) -> list[Assignment | Component]:
|
|
107
|
+
"""Force-parse anything left in the buffer (e.g., at end of stream)."""
|
|
108
|
+
out: list[Assignment | Component] = []
|
|
109
|
+
if self._buf.strip():
|
|
110
|
+
out.extend(self.feed_line(self._buf))
|
|
111
|
+
self._buf = ""
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
# --- internal parsing -------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _try_parse_top(self, src: str) -> tuple[Assignment | Component | None, int]:
|
|
117
|
+
"""Try to parse one top-level node from `src`. Returns (node, chars_consumed) or (None, 0)."""
|
|
118
|
+
src = src.lstrip()
|
|
119
|
+
if not src:
|
|
120
|
+
return None, 0
|
|
121
|
+
# assignment: identifier = ...
|
|
122
|
+
m = _IDENT.match(src)
|
|
123
|
+
if not m:
|
|
124
|
+
return None, 0
|
|
125
|
+
ident = m.group(0)
|
|
126
|
+
rest_start = m.end()
|
|
127
|
+
# peek next non-space char
|
|
128
|
+
ws = re.match(r"[ \t]*", src[rest_start:])
|
|
129
|
+
rest_start += ws.end() if ws else 0
|
|
130
|
+
if rest_start < len(src) and src[rest_start] == "=":
|
|
131
|
+
# assignment
|
|
132
|
+
after_eq = rest_start + 1
|
|
133
|
+
try:
|
|
134
|
+
node, end = self._parse_node(src, after_eq)
|
|
135
|
+
except ParseError:
|
|
136
|
+
return None, 0
|
|
137
|
+
if end is None:
|
|
138
|
+
return None, 0
|
|
139
|
+
consumed = end - (m.start() if False else 0)
|
|
140
|
+
consumed = end
|
|
141
|
+
return Assignment(identifier=ident, value=node), end
|
|
142
|
+
# component
|
|
143
|
+
try:
|
|
144
|
+
node, end = self._parse_component(src, 0)
|
|
145
|
+
except ParseError:
|
|
146
|
+
return None, 0
|
|
147
|
+
if end is None:
|
|
148
|
+
return None, 0
|
|
149
|
+
return node, end
|
|
150
|
+
|
|
151
|
+
def _skip_ws(self, src: str, i: int) -> int:
|
|
152
|
+
while i < len(src) and src[i] in " \t\n":
|
|
153
|
+
i += 1
|
|
154
|
+
return i
|
|
155
|
+
|
|
156
|
+
def _parse_node(
|
|
157
|
+
self, src: str, i: int
|
|
158
|
+
) -> tuple[Assignment | Component | Literal | Ref | None, int | None]:
|
|
159
|
+
i = self._skip_ws(src, i)
|
|
160
|
+
if i >= len(src):
|
|
161
|
+
return None, None
|
|
162
|
+
c = src[i]
|
|
163
|
+
if c == '"' or c == "'":
|
|
164
|
+
return self._parse_string(src, i)
|
|
165
|
+
if c == "-" or c.isdigit():
|
|
166
|
+
return self._parse_number(src, i)
|
|
167
|
+
if c == "[":
|
|
168
|
+
return self._parse_list(src, i)
|
|
169
|
+
if c == "$":
|
|
170
|
+
return self._parse_ref(src, i)
|
|
171
|
+
if c.isalpha() or c == "_":
|
|
172
|
+
# could be bool, component, or bare identifier (string shorthand)
|
|
173
|
+
m = _BOOL.match(src, i)
|
|
174
|
+
if m:
|
|
175
|
+
return Literal(value=(m.group(1) == "true")), m.end()
|
|
176
|
+
# peek: if followed by '=', it's a named arg's value, not a component
|
|
177
|
+
# BUT we don't know that here — we just check if the char after the
|
|
178
|
+
# ident is whitespace+')' or ',' or whitespace+ident. If so, it's
|
|
179
|
+
# actually a component. Otherwise we still treat as component (caller
|
|
180
|
+
# distinguishes). For bare ident shorthand: if the next char is
|
|
181
|
+
# NOT '(' it's a string-literal shorthand.
|
|
182
|
+
m_id = _IDENT.match(src, i)
|
|
183
|
+
if m_id:
|
|
184
|
+
after = m_id.end()
|
|
185
|
+
if after >= len(src) or src[after] != "(":
|
|
186
|
+
# bare identifier → treat as string literal
|
|
187
|
+
return Literal(value=m_id.group(0)), after
|
|
188
|
+
# try component
|
|
189
|
+
try:
|
|
190
|
+
node, end = self._parse_component(src, i)
|
|
191
|
+
except ParseError:
|
|
192
|
+
return None, None
|
|
193
|
+
return node, end
|
|
194
|
+
raise ParseError(f"unexpected char {c!r} at {i}")
|
|
195
|
+
|
|
196
|
+
def _parse_string(self, src: str, i: int) -> tuple[Literal, int]:
|
|
197
|
+
quote = src[i]
|
|
198
|
+
end = i + 1
|
|
199
|
+
buf = []
|
|
200
|
+
while end < len(src):
|
|
201
|
+
ch = src[end]
|
|
202
|
+
if ch == "\\" and end + 1 < len(src):
|
|
203
|
+
buf.append(src[end + 1])
|
|
204
|
+
end += 2
|
|
205
|
+
continue
|
|
206
|
+
if ch == quote:
|
|
207
|
+
return Literal(value="".join(buf)), end + 1
|
|
208
|
+
buf.append(ch)
|
|
209
|
+
end += 1
|
|
210
|
+
raise ParseError("unterminated string")
|
|
211
|
+
|
|
212
|
+
def _parse_number(self, src: str, i: int) -> tuple[Literal, int]:
|
|
213
|
+
m = _NUMBER.match(src, i)
|
|
214
|
+
if not m:
|
|
215
|
+
raise ParseError(f"bad number at {i}")
|
|
216
|
+
s = m.group(0)
|
|
217
|
+
val: float | int = float(s) if "." in s else int(s)
|
|
218
|
+
return Literal(value=val), m.end()
|
|
219
|
+
|
|
220
|
+
def _parse_list(self, src: str, i: int) -> tuple[Literal, int]:
|
|
221
|
+
assert src[i] == "["
|
|
222
|
+
items = []
|
|
223
|
+
i += 1
|
|
224
|
+
i = self._skip_ws(src, i)
|
|
225
|
+
if i < len(src) and src[i] == "]":
|
|
226
|
+
return Literal(value=[]), i + 1
|
|
227
|
+
while i < len(src):
|
|
228
|
+
i = self._skip_ws(src, i)
|
|
229
|
+
if i >= len(src):
|
|
230
|
+
raise ParseError("unterminated list")
|
|
231
|
+
if src[i] == "]":
|
|
232
|
+
return Literal(value=items), i + 1
|
|
233
|
+
node, end = self._parse_node(src, i)
|
|
234
|
+
if end is None:
|
|
235
|
+
raise ParseError("incomplete list element")
|
|
236
|
+
items.append(node)
|
|
237
|
+
i = end
|
|
238
|
+
i = self._skip_ws(src, i)
|
|
239
|
+
if i < len(src) and src[i] == ",":
|
|
240
|
+
i += 1
|
|
241
|
+
elif i < len(src) and src[i] == "]":
|
|
242
|
+
return Literal(value=items), i + 1
|
|
243
|
+
raise ParseError("unterminated list")
|
|
244
|
+
|
|
245
|
+
def _parse_ref(self, src: str, i: int) -> tuple[Ref, int]:
|
|
246
|
+
assert src[i] == "$"
|
|
247
|
+
i += 1
|
|
248
|
+
m = _IDENT.match(src, i)
|
|
249
|
+
if not m:
|
|
250
|
+
raise ParseError("bad ref")
|
|
251
|
+
return Ref(name=m.group(0)), m.end()
|
|
252
|
+
|
|
253
|
+
def _parse_component(
|
|
254
|
+
self, src: str, i: int
|
|
255
|
+
) -> tuple[Component, int | None]:
|
|
256
|
+
m = _IDENT.match(src, i)
|
|
257
|
+
if not m:
|
|
258
|
+
raise ParseError(f"expected identifier at {i}")
|
|
259
|
+
name = m.group(0)
|
|
260
|
+
i = m.end()
|
|
261
|
+
i = self._skip_ws(src, i)
|
|
262
|
+
if i >= len(src) or src[i] != "(":
|
|
263
|
+
# bare identifier — treat as no-arg component
|
|
264
|
+
return Component(name=name, args=[], kwargs={}), i
|
|
265
|
+
i += 1
|
|
266
|
+
args: list = []
|
|
267
|
+
kwargs: dict = {}
|
|
268
|
+
positional_idx = 0
|
|
269
|
+
def _finalize(args, kwargs):
|
|
270
|
+
"""Move trailing list-literal of Components into children."""
|
|
271
|
+
if args and isinstance(args[-1], Literal) and isinstance(args[-1].value, list):
|
|
272
|
+
if all(isinstance(c, Component) for c in args[-1].value):
|
|
273
|
+
return args[:-1], kwargs, args[-1].value
|
|
274
|
+
return args, kwargs, []
|
|
275
|
+
# parse args
|
|
276
|
+
i = self._skip_ws(src, i)
|
|
277
|
+
if i < len(src) and src[i] == ")":
|
|
278
|
+
args, kwargs, children = _finalize(args, kwargs)
|
|
279
|
+
return Component(name=name, args=args, kwargs=kwargs, children=children), i + 1
|
|
280
|
+
while i < len(src):
|
|
281
|
+
i = self._skip_ws(src, i)
|
|
282
|
+
if i >= len(src):
|
|
283
|
+
return Component(name=name), None # incomplete
|
|
284
|
+
if src[i] == ")":
|
|
285
|
+
args, kwargs, children = _finalize(args, kwargs)
|
|
286
|
+
return Component(name=name, args=args, kwargs=kwargs, children=children), i + 1
|
|
287
|
+
# check for named arg: ident =
|
|
288
|
+
save_i = i
|
|
289
|
+
m2 = _IDENT.match(src, i)
|
|
290
|
+
if m2:
|
|
291
|
+
after_ident = m2.end()
|
|
292
|
+
ws_m = re.match(r"[ \t]*", src[after_ident:])
|
|
293
|
+
peek = after_ident + (ws_m.end() if ws_m else 0)
|
|
294
|
+
if peek < len(src) and src[peek] == "=":
|
|
295
|
+
key = m2.group(0)
|
|
296
|
+
val, end = self._parse_node(src, peek + 1)
|
|
297
|
+
if end is None:
|
|
298
|
+
return Component(name=name), None
|
|
299
|
+
kwargs[key] = val
|
|
300
|
+
i = end
|
|
301
|
+
else:
|
|
302
|
+
# positional
|
|
303
|
+
val, end = self._parse_node(src, i)
|
|
304
|
+
if end is None:
|
|
305
|
+
return Component(name=name), None
|
|
306
|
+
args.append(val)
|
|
307
|
+
i = end
|
|
308
|
+
else:
|
|
309
|
+
val, end = self._parse_node(src, i)
|
|
310
|
+
if end is None:
|
|
311
|
+
return Component(name=name), None
|
|
312
|
+
args.append(val)
|
|
313
|
+
i = end
|
|
314
|
+
i = self._skip_ws(src, i)
|
|
315
|
+
if i < len(src) and src[i] == ",":
|
|
316
|
+
i += 1
|
|
317
|
+
elif i < len(src) and src[i] == ")":
|
|
318
|
+
args, kwargs, children = _finalize(args, kwargs)
|
|
319
|
+
return Component(name=name, args=args, kwargs=kwargs, children=children), i + 1
|
|
320
|
+
else:
|
|
321
|
+
# maybe end of input — let caller retry
|
|
322
|
+
return Component(name=name), None
|
|
323
|
+
raise ParseError("unterminated component args")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def count_tokens(spec: str) -> int:
|
|
327
|
+
"""Approximate token count (whitespace-split, treating punctuation as
|
|
328
|
+
separate tokens). Good enough for benchmarking. For real numbers use
|
|
329
|
+
tiktoken in benchmarks/run.py."""
|
|
330
|
+
# split on whitespace and common punctuation
|
|
331
|
+
tokens = re.findall(r"[a-zA-Z_][a-zA-Z0-9_\-]*|[(){}\[\],]|\d+|\S", spec)
|
|
332
|
+
return len(tokens)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Surgical updates: emit `Update(path=..., value=...)` instead of full re-render.
|
|
2
|
+
|
|
3
|
+
The LLM emits `Update(...)` as a top-level line. The renderer applies the
|
|
4
|
+
patch to its in-memory spec tree and re-renders only the affected nodes.
|
|
5
|
+
This is the multi-turn efficiency win — turn 2+ shouldn't pay the full
|
|
6
|
+
spec cost just to change one number.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .ast import Assignment, Component, Literal, Ref
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Update:
|
|
19
|
+
path: str # e.g. "root.children[2].kwargs.value" or "header.title"
|
|
20
|
+
value: Any # the new value (Literal, Ref, or nested Component)
|
|
21
|
+
raw_line: str = ""
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict:
|
|
24
|
+
v = self.value
|
|
25
|
+
if hasattr(v, "to_dict"):
|
|
26
|
+
v = v.to_dict()
|
|
27
|
+
return {"type": "update", "path": self.path, "value": v}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Match: Update(path="root.children[2].kwargs.value", value=42)
|
|
31
|
+
_UPDATE_RE = re.compile(
|
|
32
|
+
r'Update\s*\(\s*path\s*=\s*("[^"]+"|[A-Za-z_][\w\-.]*)\s*,\s*value\s*=\s*(.+?)\s*\)\s*$',
|
|
33
|
+
re.DOTALL,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_update(line: str, parse_value) -> Update | None:
|
|
38
|
+
"""Try to parse a line as an Update expression.
|
|
39
|
+
|
|
40
|
+
`parse_value` is a callable that parses an arbitrary value expression
|
|
41
|
+
(we pass our spec parser to avoid circular imports).
|
|
42
|
+
"""
|
|
43
|
+
m = _UPDATE_RE.match(line.strip())
|
|
44
|
+
if not m:
|
|
45
|
+
return None
|
|
46
|
+
path = m.group(1).strip('"').strip("'")
|
|
47
|
+
raw_value = m.group(2)
|
|
48
|
+
try:
|
|
49
|
+
val = parse_value(raw_value)
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
return Update(path=path, value=val, raw_line=line)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def apply_update(spec_tree: Any, update: Update) -> bool:
|
|
56
|
+
"""Apply an update to a spec tree (mutates in place).
|
|
57
|
+
|
|
58
|
+
`spec_tree` is a Component (root) or a dict (when stored as JSON).
|
|
59
|
+
|
|
60
|
+
Path syntax: dot-separated, with optional [index] for children.
|
|
61
|
+
Examples:
|
|
62
|
+
"kwargs.value" → set tree.kwargs["value"]
|
|
63
|
+
"children[2]" → tree.children[2] = value
|
|
64
|
+
"children[2].kwargs.x" → tree.children[2].kwargs["x"]
|
|
65
|
+
"""
|
|
66
|
+
parts = _tokenize_path(update.path)
|
|
67
|
+
if not parts:
|
|
68
|
+
return False
|
|
69
|
+
node = spec_tree
|
|
70
|
+
for part in parts[:-1]:
|
|
71
|
+
node = _descend(node, part)
|
|
72
|
+
if node is None:
|
|
73
|
+
return False
|
|
74
|
+
last = parts[-1]
|
|
75
|
+
val = update.value
|
|
76
|
+
if hasattr(val, "to_dict"):
|
|
77
|
+
val = val.to_dict()
|
|
78
|
+
if last["kind"] == "key":
|
|
79
|
+
if not isinstance(node, dict):
|
|
80
|
+
return False
|
|
81
|
+
node[last["name"]] = val
|
|
82
|
+
elif last["kind"] == "index":
|
|
83
|
+
if not isinstance(node, list):
|
|
84
|
+
return False
|
|
85
|
+
idx = last["index"]
|
|
86
|
+
if idx < len(node):
|
|
87
|
+
node[idx] = val
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _tokenize_path(path: str) -> list[dict]:
|
|
92
|
+
"""Tokenize "a.b[2].c" into [{'kind':'key','name':'a'}, {'kind':'key','name':'b'}, ...]"""
|
|
93
|
+
out: list[dict] = []
|
|
94
|
+
i = 0
|
|
95
|
+
name = ""
|
|
96
|
+
while i < len(path):
|
|
97
|
+
c = path[i]
|
|
98
|
+
if c == ".":
|
|
99
|
+
if name:
|
|
100
|
+
out.append({"kind": "key", "name": name})
|
|
101
|
+
name = ""
|
|
102
|
+
elif c == "[":
|
|
103
|
+
if name:
|
|
104
|
+
out.append({"kind": "key", "name": name})
|
|
105
|
+
name = ""
|
|
106
|
+
j = path.find("]", i)
|
|
107
|
+
if j < 0:
|
|
108
|
+
return []
|
|
109
|
+
try:
|
|
110
|
+
idx = int(path[i + 1 : j])
|
|
111
|
+
except ValueError:
|
|
112
|
+
return []
|
|
113
|
+
out.append({"kind": "index", "index": idx})
|
|
114
|
+
i = j
|
|
115
|
+
else:
|
|
116
|
+
name += c
|
|
117
|
+
i += 1
|
|
118
|
+
if name:
|
|
119
|
+
out.append({"kind": "key", "name": name})
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _descend(node: Any, part: dict) -> Any:
|
|
124
|
+
if part["kind"] == "key":
|
|
125
|
+
if isinstance(node, dict):
|
|
126
|
+
return node.get(part["name"])
|
|
127
|
+
if hasattr(node, "kwargs") and part["name"] in node.kwargs:
|
|
128
|
+
return node.kwargs[part["name"]]
|
|
129
|
+
if hasattr(node, "__dict__") and part["name"] in node.__dict__:
|
|
130
|
+
return getattr(node, part["name"])
|
|
131
|
+
elif part["kind"] == "index":
|
|
132
|
+
if isinstance(node, list):
|
|
133
|
+
idx = part["index"]
|
|
134
|
+
if idx < len(node):
|
|
135
|
+
return node[idx]
|
|
136
|
+
return None
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: generflow-core
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Generflow Python runtime — streams low-token UI specs over SSE, with HITL checkpoints, live data binding, action gating, and rewind/replay.
|
|
5
|
+
Author-email: Generflow <team@generflow.dev>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://generflow.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/generflow/generflow
|
|
9
|
+
Project-URL: Documentation, https://docs.generflow.dev
|
|
10
|
+
Project-URL: Issues, https://github.com/generflow/generflow/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/generflow/generflow/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: ai,generative-ui,llm,sse,agent,hitl,human-in-the-loop,design-system,streaming,tooling
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
Requires-Dist: fastapi>=0.110
|
|
28
|
+
Requires-Dist: uvicorn[standard]>=0.27
|
|
29
|
+
Requires-Dist: pydantic>=2.5
|
|
30
|
+
Requires-Dist: openai>=1.12
|
|
31
|
+
Requires-Dist: anthropic>=0.18
|
|
32
|
+
Requires-Dist: pyyaml>=6.0
|
|
33
|
+
Requires-Dist: tiktoken>=0.5
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
36
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
37
|
+
Requires-Dist: httpx>=0.26; extra == "dev"
|
|
38
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
39
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
40
|
+
Provides-Extra: all
|
|
41
|
+
Requires-Dist: generflow-core[dev]; extra == "all"
|
|
42
|
+
|
|
43
|
+
# generflow-core
|
|
44
|
+
|
|
45
|
+
> The Python runtime for [Generflow](https://generflow.dev) — open-source generative UI.
|
|
46
|
+
|
|
47
|
+
Generflow solves four problems nobody else has solved together:
|
|
48
|
+
|
|
49
|
+
1. **Token-efficient UI description** — GF-Lang is a streaming-friendly DSL that uses **57.6% fewer tokens** than equivalent JSON (benchmarked, beats JSON on 10/10 widgets).
|
|
50
|
+
2. **Data-grounded rendering** — components can declare `$ref`s to live data sources (REST, SQL, GraphQL, MCP). No fabricated numbers.
|
|
51
|
+
3. **HITL hallucination mitigation** — every render passes through a security boundary (component allow-list), and every action is gated by HITL checks (PII scan, missing source, ambiguity, low confidence).
|
|
52
|
+
4. **Cross-platform** — Web Components + React + vanilla TS renderers. Same protocol, any framework.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install generflow-core
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Or with all dev extras:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install generflow-core[dev]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Quickstart (no API key)
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from generflow_core.adapters import EchoAdapter
|
|
70
|
+
from generflow_core.spec import GFLangParser
|
|
71
|
+
from generflow_core.registry import Registry
|
|
72
|
+
import asyncio
|
|
73
|
+
|
|
74
|
+
async def main():
|
|
75
|
+
adapter = EchoAdapter()
|
|
76
|
+
registry = Registry()
|
|
77
|
+
parser = GFLangParser()
|
|
78
|
+
|
|
79
|
+
full = ""
|
|
80
|
+
async for chunk in adapter.stream("", "build a dashboard"):
|
|
81
|
+
full += chunk
|
|
82
|
+
|
|
83
|
+
for node in parser.feed_chunk(full):
|
|
84
|
+
if registry.has(node.name):
|
|
85
|
+
print(f" ✓ {node.name}")
|
|
86
|
+
|
|
87
|
+
asyncio.run(main())
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Quickstart (real LLM)
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
export OPENAI_API_KEY=sk-...
|
|
94
|
+
python -m generflow_core.api.app # → http://localhost:7878
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Then send a chat message:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
curl -X POST http://localhost:7878/v1/stream \
|
|
101
|
+
-H "Content-Type: application/json" \
|
|
102
|
+
-d '{"message": "build a sales dashboard", "session_id": "demo"}'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## CLI
|
|
106
|
+
|
|
107
|
+
The package ships a CLI for local-only rendering (no LLM, no API key):
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
generflow-render render dashboard.gf -o dashboard.html
|
|
111
|
+
generflow-render validate dashboard.gf
|
|
112
|
+
generflow-render diff before.gf after.gf
|
|
113
|
+
generflow-render to-a2ui dashboard.gf -o dashboard.a2ui.json
|
|
114
|
+
generflow-render from-a2ui incoming.a2ui.json -o spec.gf
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Architecture
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
┌──────────────────┐ SSE ┌──────────────────┐
|
|
121
|
+
│ LLM Adapter │ ─────────▶ │ SSE /v1/stream │
|
|
122
|
+
│ (OpenAI/Anthro/ │ │ │
|
|
123
|
+
│ Echo) │ │ parser (Python) │
|
|
124
|
+
└──────────────────┘ │ │ │
|
|
125
|
+
│ ▼ │
|
|
126
|
+
│ Registry │
|
|
127
|
+
│ (allow-list) │
|
|
128
|
+
│ │ │
|
|
129
|
+
│ ▼ │
|
|
130
|
+
│ DataResolver │
|
|
131
|
+
│ (REST/SQL/GQL) │
|
|
132
|
+
│ │ │
|
|
133
|
+
│ ▼ │
|
|
134
|
+
│ HITL gates │
|
|
135
|
+
│ (PII/conf/etc) │
|
|
136
|
+
│ │ │
|
|
137
|
+
│ ▼ │
|
|
138
|
+
│ Action dispatch │
|
|
139
|
+
│ (intent → HTTP) │
|
|
140
|
+
└──────────────────┘
|
|
141
|
+
│
|
|
142
|
+
▼
|
|
143
|
+
SSE events: spec.line,
|
|
144
|
+
component.mount, data.fill,
|
|
145
|
+
hitl.request, action.request,
|
|
146
|
+
update, stream.end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Protocol
|
|
150
|
+
|
|
151
|
+
Generflow's protocol is a stream of typed SSE events. See the [GF-Lang grammar](https://github.com/generflow/generflow/blob/main/docs/gf-lang.ebnf) and the [event reference](https://docs.generflow.dev/protocol).
|
|
152
|
+
|
|
153
|
+
```http
|
|
154
|
+
event: session.start
|
|
155
|
+
data: {"session_id":"demo","adapter":"openai"}
|
|
156
|
+
|
|
157
|
+
event: spec.line
|
|
158
|
+
data: {"path":"line:1","component":"Card","node":{...},"valid":true}
|
|
159
|
+
|
|
160
|
+
event: data.fill
|
|
161
|
+
data: {"ref":"monthly_revenue","ok":true,"value":[...]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
generflow_core/__init__.py,sha256=9_wGxsl3sTZAnOO5vHaBUh6Fuv1cI4vjeiJVF8hFAEg,79
|
|
2
|
+
generflow_core/cli.py,sha256=XbqsMBoYJ0-0MGZkpcTr2RPGotPBoW7XURfh296YlTY,10547
|
|
3
|
+
generflow_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
generflow_core/actions/__init__.py,sha256=mGy1yad7jxhCWxw01jOntgx77SO95PXMDvkU0eK4cWw,401
|
|
5
|
+
generflow_core/actions/dispatcher.py,sha256=O5wA_Ex45r5EZawZNyJuipCtnj1UFfAyPhpRrY7PZC8,7102
|
|
6
|
+
generflow_core/adapters/__init__.py,sha256=Q6xYvblGccMLT3o-YZ3m6onGYh0AMCf7S4o9dJtFAO0,312
|
|
7
|
+
generflow_core/adapters/llm.py,sha256=J9N6MLvNJWzOg3khrMGi_FQenfVWmg1KPfihcOoB3BE,5665
|
|
8
|
+
generflow_core/api/__init__.py,sha256=gS04y9nwp8fHbgV4ZXPly8ULHXDhyZ_-nSMwLeM_H4Q,170
|
|
9
|
+
generflow_core/api/app.py,sha256=oSYwWPNZ1Cq6eQpgGUHpnx8Nww-6e1AEH2KlzADvIjo,18662
|
|
10
|
+
generflow_core/api/prompt.py,sha256=rkgsqkrA1FGYlkGFkptXtg46P8AUSRm44K97WVef7Q4,2900
|
|
11
|
+
generflow_core/databind/__init__.py,sha256=DiSEDrLggOrdK8i9oaChFOdykrrvowjj82EOZ_e0lso,577
|
|
12
|
+
generflow_core/databind/config.py,sha256=ME-1aIFD-WEWFJhqX08WFAQmmDZazHTGzM60QY07fJY,6546
|
|
13
|
+
generflow_core/databind/resolver.py,sha256=WElpGRKCxk6iRIcEXIM0au24PvHhAtJ0N2JeNj1vLI0,11635
|
|
14
|
+
generflow_core/hitl/__init__.py,sha256=M5YJOtyFOjFmFAEJhbOPzWuJzI2kNOu6iN_ncdBP4zg,418
|
|
15
|
+
generflow_core/hitl/gates.py,sha256=TTqi6pbhFdzF7t5WBLcYc-8b-fLvC9xBvjk6c-Hkd4M,6119
|
|
16
|
+
generflow_core/interop/__init__.py,sha256=rE-DV3pnpX5aX8tyDcDMhlMX_ME1spZ7K9lcEyJhRmk,8773
|
|
17
|
+
generflow_core/observability/__init__.py,sha256=BEKiFiWW3G3Qh87T2WFdL0rgLBQqXifHaJ49vDNK0vA,8145
|
|
18
|
+
generflow_core/registry/__init__.py,sha256=1bNFobFtUwkVfRiMnBWZbdEOLHGu17R9bymSIt8963M,253
|
|
19
|
+
generflow_core/registry/registry.py,sha256=LhFAVHUZfT4BldSbnve0jrGWVlFlSW3WuumjqPZQPas,8891
|
|
20
|
+
generflow_core/replay/__init__.py,sha256=6NiTo6j4I9BG466rrWvNlBTJCbo4x2oas7NyQeg55CQ,6724
|
|
21
|
+
generflow_core/spec/__init__.py,sha256=oJtqqgkL_tr6INxZ39HJQ1QClRDe9sBQ2vxeRY6X-x0,510
|
|
22
|
+
generflow_core/spec/ast.py,sha256=GcQGMVDwZoZNh24APmF9wge6BtjOtDySV3OjsXKb5wY,1493
|
|
23
|
+
generflow_core/spec/diff.py,sha256=amfAvP1kYKYV4Ol4xXjPKGDgoYqlnyt0E-KGCnF9ubw,6823
|
|
24
|
+
generflow_core/spec/parser.py,sha256=Y6Qc08m0uayciO9UxVmsipN4lacZj10HBqSOPocYw6c,12811
|
|
25
|
+
generflow_core/spec/update.py,sha256=XM-tJMPYEvaGij-Up8P9I4YDkwUrlnGr5Z4I9tkckVw,4181
|
|
26
|
+
generflow_core-0.2.0.dist-info/METADATA,sha256=B-rCYy4UZruDx3dVWcvl4603_79h_yryJPjOOHY3Ows,6199
|
|
27
|
+
generflow_core-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
28
|
+
generflow_core-0.2.0.dist-info/entry_points.txt,sha256=we2c_hJ4DOq3jFZxfkXRXijx3fLU-zKrcD6N7GgFgWI,101
|
|
29
|
+
generflow_core-0.2.0.dist-info/top_level.txt,sha256=BD7V2_uvP1hK4AG78t58JF6-ignzxGYIasrHIfG-m64,15
|
|
30
|
+
generflow_core-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
generflow_core
|