generflow-core 0.2.0__tar.gz
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-0.2.0/PKG-INFO +161 -0
- generflow_core-0.2.0/README.md +119 -0
- generflow_core-0.2.0/pyproject.toml +91 -0
- generflow_core-0.2.0/setup.cfg +4 -0
- generflow_core-0.2.0/src/generflow_core/__init__.py +3 -0
- generflow_core-0.2.0/src/generflow_core/actions/__init__.py +22 -0
- generflow_core-0.2.0/src/generflow_core/actions/dispatcher.py +223 -0
- generflow_core-0.2.0/src/generflow_core/adapters/__init__.py +11 -0
- generflow_core-0.2.0/src/generflow_core/adapters/llm.py +186 -0
- generflow_core-0.2.0/src/generflow_core/api/__init__.py +5 -0
- generflow_core-0.2.0/src/generflow_core/api/app.py +494 -0
- generflow_core-0.2.0/src/generflow_core/api/prompt.py +64 -0
- generflow_core-0.2.0/src/generflow_core/cli.py +241 -0
- generflow_core-0.2.0/src/generflow_core/databind/__init__.py +30 -0
- generflow_core-0.2.0/src/generflow_core/databind/config.py +183 -0
- generflow_core-0.2.0/src/generflow_core/databind/resolver.py +306 -0
- generflow_core-0.2.0/src/generflow_core/hitl/__init__.py +22 -0
- generflow_core-0.2.0/src/generflow_core/hitl/gates.py +165 -0
- generflow_core-0.2.0/src/generflow_core/interop/__init__.py +257 -0
- generflow_core-0.2.0/src/generflow_core/observability/__init__.py +208 -0
- generflow_core-0.2.0/src/generflow_core/py.typed +0 -0
- generflow_core-0.2.0/src/generflow_core/registry/__init__.py +4 -0
- generflow_core-0.2.0/src/generflow_core/registry/registry.py +194 -0
- generflow_core-0.2.0/src/generflow_core/replay/__init__.py +189 -0
- generflow_core-0.2.0/src/generflow_core/spec/__init__.py +21 -0
- generflow_core-0.2.0/src/generflow_core/spec/ast.py +61 -0
- generflow_core-0.2.0/src/generflow_core/spec/diff.py +177 -0
- generflow_core-0.2.0/src/generflow_core/spec/parser.py +332 -0
- generflow_core-0.2.0/src/generflow_core/spec/update.py +136 -0
- generflow_core-0.2.0/src/generflow_core.egg-info/PKG-INFO +161 -0
- generflow_core-0.2.0/src/generflow_core.egg-info/SOURCES.txt +36 -0
- generflow_core-0.2.0/src/generflow_core.egg-info/dependency_links.txt +1 -0
- generflow_core-0.2.0/src/generflow_core.egg-info/entry_points.txt +3 -0
- generflow_core-0.2.0/src/generflow_core.egg-info/requires.txt +17 -0
- generflow_core-0.2.0/src/generflow_core.egg-info/top_level.txt +1 -0
- generflow_core-0.2.0/tests/test_parser.py +76 -0
- generflow_core-0.2.0/tests/test_phase2.py +248 -0
- generflow_core-0.2.0/tests/test_phase3.py +288 -0
|
@@ -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,119 @@
|
|
|
1
|
+
# generflow-core
|
|
2
|
+
|
|
3
|
+
> The Python runtime for [Generflow](https://generflow.dev) — open-source generative UI.
|
|
4
|
+
|
|
5
|
+
Generflow solves four problems nobody else has solved together:
|
|
6
|
+
|
|
7
|
+
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).
|
|
8
|
+
2. **Data-grounded rendering** — components can declare `$ref`s to live data sources (REST, SQL, GraphQL, MCP). No fabricated numbers.
|
|
9
|
+
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).
|
|
10
|
+
4. **Cross-platform** — Web Components + React + vanilla TS renderers. Same protocol, any framework.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install generflow-core
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or with all dev extras:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install generflow-core[dev]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quickstart (no API key)
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from generflow_core.adapters import EchoAdapter
|
|
28
|
+
from generflow_core.spec import GFLangParser
|
|
29
|
+
from generflow_core.registry import Registry
|
|
30
|
+
import asyncio
|
|
31
|
+
|
|
32
|
+
async def main():
|
|
33
|
+
adapter = EchoAdapter()
|
|
34
|
+
registry = Registry()
|
|
35
|
+
parser = GFLangParser()
|
|
36
|
+
|
|
37
|
+
full = ""
|
|
38
|
+
async for chunk in adapter.stream("", "build a dashboard"):
|
|
39
|
+
full += chunk
|
|
40
|
+
|
|
41
|
+
for node in parser.feed_chunk(full):
|
|
42
|
+
if registry.has(node.name):
|
|
43
|
+
print(f" ✓ {node.name}")
|
|
44
|
+
|
|
45
|
+
asyncio.run(main())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quickstart (real LLM)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
export OPENAI_API_KEY=sk-...
|
|
52
|
+
python -m generflow_core.api.app # → http://localhost:7878
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Then send a chat message:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
curl -X POST http://localhost:7878/v1/stream \
|
|
59
|
+
-H "Content-Type: application/json" \
|
|
60
|
+
-d '{"message": "build a sales dashboard", "session_id": "demo"}'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## CLI
|
|
64
|
+
|
|
65
|
+
The package ships a CLI for local-only rendering (no LLM, no API key):
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
generflow-render render dashboard.gf -o dashboard.html
|
|
69
|
+
generflow-render validate dashboard.gf
|
|
70
|
+
generflow-render diff before.gf after.gf
|
|
71
|
+
generflow-render to-a2ui dashboard.gf -o dashboard.a2ui.json
|
|
72
|
+
generflow-render from-a2ui incoming.a2ui.json -o spec.gf
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Architecture
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
┌──────────────────┐ SSE ┌──────────────────┐
|
|
79
|
+
│ LLM Adapter │ ─────────▶ │ SSE /v1/stream │
|
|
80
|
+
│ (OpenAI/Anthro/ │ │ │
|
|
81
|
+
│ Echo) │ │ parser (Python) │
|
|
82
|
+
└──────────────────┘ │ │ │
|
|
83
|
+
│ ▼ │
|
|
84
|
+
│ Registry │
|
|
85
|
+
│ (allow-list) │
|
|
86
|
+
│ │ │
|
|
87
|
+
│ ▼ │
|
|
88
|
+
│ DataResolver │
|
|
89
|
+
│ (REST/SQL/GQL) │
|
|
90
|
+
│ │ │
|
|
91
|
+
│ ▼ │
|
|
92
|
+
│ HITL gates │
|
|
93
|
+
│ (PII/conf/etc) │
|
|
94
|
+
│ │ │
|
|
95
|
+
│ ▼ │
|
|
96
|
+
│ Action dispatch │
|
|
97
|
+
│ (intent → HTTP) │
|
|
98
|
+
└──────────────────┘
|
|
99
|
+
│
|
|
100
|
+
▼
|
|
101
|
+
SSE events: spec.line,
|
|
102
|
+
component.mount, data.fill,
|
|
103
|
+
hitl.request, action.request,
|
|
104
|
+
update, stream.end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Protocol
|
|
108
|
+
|
|
109
|
+
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).
|
|
110
|
+
|
|
111
|
+
```http
|
|
112
|
+
event: session.start
|
|
113
|
+
data: {"session_id":"demo","adapter":"openai"}
|
|
114
|
+
|
|
115
|
+
event: spec.line
|
|
116
|
+
data: {"path":"line:1","component":"Card","node":{...},"valid":true}
|
|
117
|
+
|
|
118
|
+
event: data.fill
|
|
119
|
+
data: {"ref":"monthly_revenue","ok":true,"value":[...]}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel", "setuptools_scm>=8"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "generflow-core"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Generflow Python runtime — streams low-token UI specs over SSE, with HITL checkpoints, live data binding, action gating, and rewind/replay."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Generflow", email = "team@generflow.dev" }] # placeholder
|
|
13
|
+
keywords = [
|
|
14
|
+
"ai",
|
|
15
|
+
"generative-ui",
|
|
16
|
+
"llm",
|
|
17
|
+
"sse",
|
|
18
|
+
"agent",
|
|
19
|
+
"hitl",
|
|
20
|
+
"human-in-the-loop",
|
|
21
|
+
"design-system",
|
|
22
|
+
"streaming",
|
|
23
|
+
"tooling",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Framework :: FastAPI",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"License :: OSI Approved :: Apache Software License",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.10",
|
|
33
|
+
"Programming Language :: Python :: 3.11",
|
|
34
|
+
"Programming Language :: Python :: 3.12",
|
|
35
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
36
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
37
|
+
"Typing :: Typed",
|
|
38
|
+
]
|
|
39
|
+
dependencies = [
|
|
40
|
+
"fastapi>=0.110",
|
|
41
|
+
"uvicorn[standard]>=0.27",
|
|
42
|
+
"pydantic>=2.5",
|
|
43
|
+
"openai>=1.12",
|
|
44
|
+
"anthropic>=0.18",
|
|
45
|
+
"pyyaml>=6.0",
|
|
46
|
+
"tiktoken>=0.5",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[project.optional-dependencies]
|
|
50
|
+
dev = [
|
|
51
|
+
"pytest>=7.4",
|
|
52
|
+
"pytest-asyncio>=0.23",
|
|
53
|
+
"httpx>=0.26",
|
|
54
|
+
"build>=1.0",
|
|
55
|
+
"twine>=4.0",
|
|
56
|
+
]
|
|
57
|
+
all = ["generflow-core[dev]"]
|
|
58
|
+
|
|
59
|
+
[project.urls]
|
|
60
|
+
Homepage = "https://generflow.dev"
|
|
61
|
+
Repository = "https://github.com/generflow/generflow"
|
|
62
|
+
Documentation = "https://docs.generflow.dev"
|
|
63
|
+
Issues = "https://github.com/generflow/generflow/issues"
|
|
64
|
+
Changelog = "https://github.com/generflow/generflow/blob/main/CHANGELOG.md"
|
|
65
|
+
|
|
66
|
+
[project.scripts]
|
|
67
|
+
generflow = "generflow_core.api.app:main"
|
|
68
|
+
generflow-render = "generflow_core.cli:main"
|
|
69
|
+
|
|
70
|
+
[tool.setuptools.packages.find]
|
|
71
|
+
where = ["src"]
|
|
72
|
+
include = ["generflow_core*"]
|
|
73
|
+
|
|
74
|
+
[tool.setuptools.package-data]
|
|
75
|
+
generflow_core = ["py.typed"]
|
|
76
|
+
|
|
77
|
+
[tool.setuptools.package-dir]
|
|
78
|
+
"" = "src"
|
|
79
|
+
|
|
80
|
+
[tool.pytest.ini_options]
|
|
81
|
+
testpaths = ["tests"]
|
|
82
|
+
python_files = ["test_*.py"]
|
|
83
|
+
python_functions = ["test_*"]
|
|
84
|
+
addopts = "-v --tb=short"
|
|
85
|
+
filterwarnings = [
|
|
86
|
+
"ignore::DeprecationWarning:datetime.*",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[tool.build_sphinx]
|
|
90
|
+
source-dir = "docs"
|
|
91
|
+
build-dir = "docs/_build"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Action module: intent dispatch + audit."""
|
|
2
|
+
from .dispatcher import (
|
|
3
|
+
ActionError,
|
|
4
|
+
ActionResult,
|
|
5
|
+
DispatchPlan,
|
|
6
|
+
audit_clear,
|
|
7
|
+
audit_recent,
|
|
8
|
+
audit_record,
|
|
9
|
+
build_dispatch,
|
|
10
|
+
execute_dispatch,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ActionError",
|
|
15
|
+
"ActionResult",
|
|
16
|
+
"DispatchPlan",
|
|
17
|
+
"audit_clear",
|
|
18
|
+
"audit_recent",
|
|
19
|
+
"audit_record",
|
|
20
|
+
"build_dispatch",
|
|
21
|
+
"execute_dispatch",
|
|
22
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Action dispatcher: intent → configured endpoint, with HITL confirm.
|
|
2
|
+
|
|
3
|
+
The LLM emits `intent="refund.approve"` on a Button. This module:
|
|
4
|
+
1. Looks up the intent in the app config
|
|
5
|
+
2. Merges the bound values (form state) into the body template
|
|
6
|
+
3. Returns a dispatch plan with confirm-required flag
|
|
7
|
+
|
|
8
|
+
The actual HTTP call is made by the SSE handler after the user
|
|
9
|
+
confirms via the HITL gate. This separation lets us:
|
|
10
|
+
- Audit the intent *before* the call
|
|
11
|
+
- Show the user exactly what will be sent
|
|
12
|
+
- Cancel cleanly if they back out
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..databind import AppConfig, Action
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ActionError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class DispatchPlan:
|
|
30
|
+
"""A fully-resolved plan for an action call, ready for user confirmation.
|
|
31
|
+
|
|
32
|
+
Not yet executed — `executed` is False until the HITL gate (or auto-approve)
|
|
33
|
+
fires the actual request.
|
|
34
|
+
"""
|
|
35
|
+
intent: str
|
|
36
|
+
action: Action
|
|
37
|
+
method: str
|
|
38
|
+
url: str
|
|
39
|
+
headers: dict
|
|
40
|
+
body: dict
|
|
41
|
+
payload_hash: str
|
|
42
|
+
confirm_required: bool
|
|
43
|
+
requires_role: str | None
|
|
44
|
+
audit: bool
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
return {
|
|
48
|
+
"intent": self.intent,
|
|
49
|
+
"method": self.method,
|
|
50
|
+
"url": self.url,
|
|
51
|
+
"headers": {k: v for k, v in self.headers.items() if k.lower() != "authorization"},
|
|
52
|
+
"body": self.body,
|
|
53
|
+
"payload_hash": self.payload_hash,
|
|
54
|
+
"confirm_required": self.confirm_required,
|
|
55
|
+
"requires_role": self.requires_role,
|
|
56
|
+
"audit": self.audit,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_dispatch(
|
|
61
|
+
config: AppConfig,
|
|
62
|
+
intent: str,
|
|
63
|
+
bindings: dict[str, Any] | None = None,
|
|
64
|
+
) -> DispatchPlan:
|
|
65
|
+
"""Build a dispatch plan from an intent + bound values.
|
|
66
|
+
|
|
67
|
+
`bindings` is the form state or other values the LLM specified via
|
|
68
|
+
`bind=[...]` on the Button. Only keys in the action's allow-listed
|
|
69
|
+
`bind` are accepted — everything else is dropped (field smuggling
|
|
70
|
+
prevention).
|
|
71
|
+
"""
|
|
72
|
+
action = config.action(intent)
|
|
73
|
+
if action is None:
|
|
74
|
+
raise ActionError(f"Unknown action intent: {intent!r}")
|
|
75
|
+
bindings = bindings or {}
|
|
76
|
+
# enforce bind allow-list
|
|
77
|
+
allowed = set(action.bind)
|
|
78
|
+
sanitized: dict[str, Any] = {}
|
|
79
|
+
for k, v in bindings.items():
|
|
80
|
+
if k in allowed:
|
|
81
|
+
sanitized[k] = v
|
|
82
|
+
# merge into body template — keep original types (int, str, etc.)
|
|
83
|
+
body = _render_template(action.body_template, sanitized)
|
|
84
|
+
# no-op for now: keep body as-is. The binding type flows through
|
|
85
|
+
# the renderer; downstream JSON serialization handles the rest.
|
|
86
|
+
# interpolate URL params
|
|
87
|
+
url = action.url
|
|
88
|
+
for k, v in sanitized.items():
|
|
89
|
+
url = url.replace(f"${k}", str(v))
|
|
90
|
+
# payload hash (used for audit dedup + render preview)
|
|
91
|
+
payload = json.dumps(body, sort_keys=True, default=str)
|
|
92
|
+
payload_hash = hashlib.sha256(payload.encode()).hexdigest()[:16]
|
|
93
|
+
# auth: bearer is interpolated from env at config-load time,
|
|
94
|
+
# so we just need to copy the configured headers
|
|
95
|
+
return DispatchPlan(
|
|
96
|
+
intent=intent,
|
|
97
|
+
action=action,
|
|
98
|
+
method=action.method,
|
|
99
|
+
url=url,
|
|
100
|
+
headers=dict(action.headers),
|
|
101
|
+
body=body,
|
|
102
|
+
payload_hash=payload_hash,
|
|
103
|
+
confirm_required=action.confirm,
|
|
104
|
+
requires_role=action.requires_role,
|
|
105
|
+
audit=action.audit,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _render_template(template: Any, values: dict[str, Any]) -> Any:
|
|
110
|
+
"""Recursively replace `$key` in strings with `values[key]`."""
|
|
111
|
+
if isinstance(template, str):
|
|
112
|
+
out = template
|
|
113
|
+
for k, v in values.items():
|
|
114
|
+
out = out.replace(f"${k}", str(v))
|
|
115
|
+
return out
|
|
116
|
+
if isinstance(template, dict):
|
|
117
|
+
return {k: _render_template(v, values) for k, v in template.items()}
|
|
118
|
+
if isinstance(template, list):
|
|
119
|
+
return [_render_template(v, values) for v in template]
|
|
120
|
+
return template
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _coerce_types(body: Any) -> Any:
|
|
124
|
+
"""If a value came back as a numeric string ("89") but the binding was
|
|
125
|
+
numeric (89), restore the original type. Keeps dispatch payloads
|
|
126
|
+
type-correct for downstream APIs."""
|
|
127
|
+
if isinstance(body, dict):
|
|
128
|
+
return {k: _coerce_types(v) for k, v in body.items()}
|
|
129
|
+
if isinstance(body, list):
|
|
130
|
+
return [_coerce_types(v) for v in body]
|
|
131
|
+
if isinstance(body, str):
|
|
132
|
+
s = body.strip()
|
|
133
|
+
if s and (s[0].isdigit() or (s[0] == "-" and len(s) > 1 and s[1].isdigit())):
|
|
134
|
+
try:
|
|
135
|
+
if "." in s:
|
|
136
|
+
return float(s)
|
|
137
|
+
return int(s)
|
|
138
|
+
except ValueError:
|
|
139
|
+
pass
|
|
140
|
+
return body
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── Execution ──────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
import asyncio
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ActionResult:
|
|
149
|
+
def __init__(self, status: int, body: Any, ok: bool, error: str | None = None) -> None:
|
|
150
|
+
self.status = status
|
|
151
|
+
self.body = body
|
|
152
|
+
self.ok = ok
|
|
153
|
+
self.error = error
|
|
154
|
+
|
|
155
|
+
def to_dict(self) -> dict:
|
|
156
|
+
return {"status": self.status, "ok": self.ok, "error": self.error, "body": self.body}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def execute_dispatch(plan: DispatchPlan) -> ActionResult:
|
|
160
|
+
"""Execute a confirmed dispatch plan. Sends the actual HTTP request."""
|
|
161
|
+
import httpx
|
|
162
|
+
try:
|
|
163
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
164
|
+
r = await client.request(
|
|
165
|
+
plan.method,
|
|
166
|
+
plan.url,
|
|
167
|
+
json=plan.body,
|
|
168
|
+
headers=plan.headers,
|
|
169
|
+
)
|
|
170
|
+
try:
|
|
171
|
+
body = r.json()
|
|
172
|
+
except Exception:
|
|
173
|
+
body = r.text
|
|
174
|
+
return ActionResult(status=r.status_code, body=body, ok=r.is_success)
|
|
175
|
+
except Exception as e:
|
|
176
|
+
return ActionResult(status=0, body=None, ok=False, error=str(e))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── Audit log ─────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
import datetime
|
|
182
|
+
import threading
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_AUDIT_LOG: list[dict] = []
|
|
186
|
+
_AUDIT_LOCK = threading.Lock()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def audit_record(
|
|
190
|
+
intent: str,
|
|
191
|
+
user: str,
|
|
192
|
+
payload_hash: str,
|
|
193
|
+
status: int,
|
|
194
|
+
latency_ms: int,
|
|
195
|
+
extra: dict | None = None,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Append a record to the in-memory audit log.
|
|
198
|
+
|
|
199
|
+
Production would back this with Postgres / OpenTelemetry / etc.
|
|
200
|
+
For v1, an in-memory list is enough to demonstrate the shape.
|
|
201
|
+
"""
|
|
202
|
+
rec = {
|
|
203
|
+
"ts": datetime.datetime.utcnow().isoformat() + "Z",
|
|
204
|
+
"intent": intent,
|
|
205
|
+
"user": user,
|
|
206
|
+
"payload_hash": payload_hash,
|
|
207
|
+
"status": status,
|
|
208
|
+
"latency_ms": latency_ms,
|
|
209
|
+
}
|
|
210
|
+
if extra:
|
|
211
|
+
rec.update(extra)
|
|
212
|
+
with _AUDIT_LOCK:
|
|
213
|
+
_AUDIT_LOG.append(rec)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def audit_recent(limit: int = 50) -> list[dict]:
|
|
217
|
+
with _AUDIT_LOCK:
|
|
218
|
+
return list(_AUDIT_LOG[-limit:])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def audit_clear() -> None:
|
|
222
|
+
with _AUDIT_LOCK:
|
|
223
|
+
_AUDIT_LOG.clear()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""LLM adapters — pluggable backends (OpenAI, Anthropic, Echo for local dev)."""
|
|
2
|
+
from .llm import AnthropicAdapter, EchoAdapter, LLMAdapter, LLMError, OpenAIAdapter, get_adapter
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"AnthropicAdapter",
|
|
6
|
+
"EchoAdapter",
|
|
7
|
+
"LLMAdapter",
|
|
8
|
+
"LLMError",
|
|
9
|
+
"OpenAIAdapter",
|
|
10
|
+
"get_adapter",
|
|
11
|
+
]
|