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
generflow_core/cli.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Local-only CLI: render a GF-Lang spec file to HTML/JSON without an LLM.
|
|
2
|
+
|
|
3
|
+
Useful for:
|
|
4
|
+
- Static dashboard embedding
|
|
5
|
+
- Storybook integration
|
|
6
|
+
- Design-system handoff (designer writes a spec, dev renders it)
|
|
7
|
+
- Email-renderable reports (spec → HTML → email)
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python -m generflow_core.cli render spec.gf -o output.html
|
|
11
|
+
python -m generflow_core.cli validate spec.gf
|
|
12
|
+
python -m generflow_core.cli diff before.gf after.gf
|
|
13
|
+
python -m generflow_core.cli to-a2ui spec.gf -o output.a2ui.json
|
|
14
|
+
python -m generflow_core.cli from-a2ui incoming.a2ui.json -o spec.gf
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .spec import GFLangParser, diff_specs, summarize_diff
|
|
24
|
+
from .registry import Registry
|
|
25
|
+
from .interop import a2ui_to_gflang, gflang_to_a2ui
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Render: spec → HTML ───────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
def render_html(spec_text: str, registry: Registry | None = None) -> str:
|
|
31
|
+
"""Render a GF-Lang spec to a self-contained HTML document.
|
|
32
|
+
|
|
33
|
+
The HTML has inline CSS and minimal JS. No React, no Tailwind CDN —
|
|
34
|
+
everything's a single file you can drop in an email or iframe.
|
|
35
|
+
"""
|
|
36
|
+
registry = registry or Registry()
|
|
37
|
+
parser = GFLangParser()
|
|
38
|
+
nodes = parser.feed_chunk(spec_text)
|
|
39
|
+
if not nodes:
|
|
40
|
+
return _empty_html("(empty spec)")
|
|
41
|
+
root = nodes[0]
|
|
42
|
+
if hasattr(root, "value"):
|
|
43
|
+
root = root.value
|
|
44
|
+
|
|
45
|
+
body = _render_node_html(root, registry)
|
|
46
|
+
return f"""<!doctype html>
|
|
47
|
+
<html><head>
|
|
48
|
+
<meta charset="utf-8">
|
|
49
|
+
<title>Generflow render</title>
|
|
50
|
+
<style>
|
|
51
|
+
body {{ font: 14px/1.5 system-ui, -apple-system, sans-serif; margin: 0; padding: 24px; background: #fafafa; color: #1a1a1a; }}
|
|
52
|
+
.gf-card {{ background: white; border: 1px solid #e5e5e5; border-radius: 8px; padding: 16px; margin: 12px 0; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }}
|
|
53
|
+
.gf-card > .gf-title {{ font-size: 18px; font-weight: 600; margin-bottom: 12px; color: #111; }}
|
|
54
|
+
.gf-stack, .gf-row, .gf-col {{ display: flex; gap: 12px; }}
|
|
55
|
+
.gf-col {{ flex-direction: column; }}
|
|
56
|
+
.gf-header {{ font-weight: 600; margin: 8px 0; }}
|
|
57
|
+
.gf-header.h1 {{ font-size: 24px; }} .gf-header.h2 {{ font-size: 18px; }} .gf-header.h3 {{ font-size: 14px; }}
|
|
58
|
+
.gf-text {{ margin: 4px 0; }}
|
|
59
|
+
.gf-metric {{ display: flex; flex-direction: column; padding: 12px; background: #f8f8f8; border-radius: 6px; flex: 1; }}
|
|
60
|
+
.gf-metric .label {{ font-size: 12px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }}
|
|
61
|
+
.gf-metric .value {{ font-size: 28px; font-weight: 700; color: #111; margin-top: 4px; }}
|
|
62
|
+
.gf-metric .delta-positive {{ color: #10b981; font-size: 12px; }}
|
|
63
|
+
.gf-metric .delta-negative {{ color: #ef4444; font-size: 12px; }}
|
|
64
|
+
.gf-button {{ background: #2563eb; color: white; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; font-weight: 500; }}
|
|
65
|
+
.gf-button:hover {{ background: #1d4ed8; }}
|
|
66
|
+
.gf-form {{ background: white; border: 1px solid #e5e5e5; border-radius: 8px; padding: 16px; }}
|
|
67
|
+
.gf-field {{ display: flex; flex-direction: column; margin: 8px 0; }}
|
|
68
|
+
.gf-field label {{ font-size: 12px; color: #666; margin-bottom: 4px; }}
|
|
69
|
+
.gf-field input {{ padding: 8px; border: 1px solid #ddd; border-radius: 4px; }}
|
|
70
|
+
.gf-list {{ list-style: none; padding: 0; }}
|
|
71
|
+
.gf-list li {{ padding: 8px 12px; border-bottom: 1px solid #f0f0f0; }}
|
|
72
|
+
.gf-chart {{ background: #f8f8f8; border-radius: 6px; padding: 12px; min-height: 120px; display: flex; align-items: center; justify-content: center; color: #666; }}
|
|
73
|
+
.gf-meta {{ font-size: 11px; color: #999; margin-top: 24px; padding-top: 12px; border-top: 1px solid #eee; }}
|
|
74
|
+
</style>
|
|
75
|
+
</head><body>
|
|
76
|
+
{body}
|
|
77
|
+
<div class="gf-meta">Rendered by Generflow · {len(nodes)} top-level node(s)</div>
|
|
78
|
+
</body></html>"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _render_node_html(node, registry: Registry) -> str:
|
|
82
|
+
from .spec.ast import Component, Literal
|
|
83
|
+
if not isinstance(node, Component):
|
|
84
|
+
return ""
|
|
85
|
+
name = node.name
|
|
86
|
+
kwargs = {k: (v.value if isinstance(v, Literal) else str(v)) for k, v in node.kwargs.items()}
|
|
87
|
+
children_html = ""
|
|
88
|
+
# read children from new Component.children field first; fall back to args[-1]
|
|
89
|
+
children = getattr(node, "children", None)
|
|
90
|
+
if children:
|
|
91
|
+
children_html = "".join(_render_node_html(c, registry) for c in children if isinstance(c, Component))
|
|
92
|
+
elif node.args:
|
|
93
|
+
last = node.args[-1]
|
|
94
|
+
if isinstance(last, Literal) and isinstance(last.value, list):
|
|
95
|
+
children_html = "".join(_render_node_html(c, registry) for c in last.value if isinstance(c, Component))
|
|
96
|
+
|
|
97
|
+
if name == "Card":
|
|
98
|
+
title = kwargs.get("title", "")
|
|
99
|
+
title_html = f'<div class="gf-title">{title}</div>' if title else ""
|
|
100
|
+
return f'<div class="gf-card">{title_html}{children_html}</div>'
|
|
101
|
+
if name in ("Stack", "Col"):
|
|
102
|
+
return f'<div class="gf-col">{children_html}</div>'
|
|
103
|
+
if name == "Row":
|
|
104
|
+
return f'<div class="gf-row">{children_html}</div>'
|
|
105
|
+
if name == "Header":
|
|
106
|
+
lvl = kwargs.get("level", "1")
|
|
107
|
+
text = kwargs.get("text", "")
|
|
108
|
+
return f'<div class="gf-header h{lvl}">{text}</div>'
|
|
109
|
+
if name == "Text":
|
|
110
|
+
text = kwargs.get("text", "")
|
|
111
|
+
return f'<div class="gf-text">{text}</div>'
|
|
112
|
+
if name == "Metric":
|
|
113
|
+
label = kwargs.get("label", "")
|
|
114
|
+
value = kwargs.get("value", "—")
|
|
115
|
+
delta = kwargs.get("delta", "")
|
|
116
|
+
delta_cls = "delta-positive" if delta.startswith("+") else ("delta-negative" if delta.startswith("-") else "")
|
|
117
|
+
delta_html = f'<span class="{delta_cls}">{delta}</span>' if delta else ""
|
|
118
|
+
return f'<div class="gf-metric"><span class="label">{label}</span><span class="value">{value}</span>{delta_html}</div>'
|
|
119
|
+
if name == "Chart":
|
|
120
|
+
title = kwargs.get("title", "")
|
|
121
|
+
ctype = kwargs.get("type", "bar")
|
|
122
|
+
return f'<div class="gf-chart">[Chart: {title or ctype}]</div>'
|
|
123
|
+
if name == "Button":
|
|
124
|
+
label = kwargs.get("label", "")
|
|
125
|
+
intent = kwargs.get("intent", "")
|
|
126
|
+
intent_attr = f' data-intent="{intent}"' if intent else ""
|
|
127
|
+
return f'<button class="gf-button"{intent_attr}>{label}</button>'
|
|
128
|
+
if name == "Form":
|
|
129
|
+
title = kwargs.get("title", "")
|
|
130
|
+
title_html = f'<h3>{title}</h3>' if title else ""
|
|
131
|
+
return f'<div class="gf-form">{title_html}{children_html}</div>'
|
|
132
|
+
if name == "Field":
|
|
133
|
+
label = kwargs.get("label", "")
|
|
134
|
+
name_attr = kwargs.get("name", "")
|
|
135
|
+
ftype = kwargs.get("type", "text")
|
|
136
|
+
return f'<div class="gf-field"><label>{label}</label><input type="{ftype}" name="{name_attr}"></div>'
|
|
137
|
+
if name == "List":
|
|
138
|
+
return f'<ul class="gf-list">{children_html or "<li>(list — data source not resolved in static mode)</li>"}</ul>'
|
|
139
|
+
if name == "Navbar":
|
|
140
|
+
title = kwargs.get("title", "")
|
|
141
|
+
return f'<nav class="gf-card"><div class="gf-title">{title}</div></nav>'
|
|
142
|
+
if name == "Modal":
|
|
143
|
+
return f'<div class="gf-card"><em>(Modal — not interactive in static mode)</em>{children_html}</div>'
|
|
144
|
+
# unknown component — render as warning
|
|
145
|
+
return f'<div class="gf-text" style="color:#999">[Unknown component: {name}]</div>'
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _empty_html(msg: str) -> str:
|
|
149
|
+
return f'<!doctype html><html><body><p>{msg}</p></body></html>'
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── CLI ───────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
def main() -> int:
|
|
155
|
+
parser = argparse.ArgumentParser(prog="generflow", description="Generflow local-only CLI")
|
|
156
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
157
|
+
|
|
158
|
+
p_render = sub.add_parser("render", help="Render a GF-Lang spec to HTML")
|
|
159
|
+
p_render.add_argument("spec", type=Path, help="Path to .gf file")
|
|
160
|
+
p_render.add_argument("-o", "--output", type=Path, default=None)
|
|
161
|
+
|
|
162
|
+
p_validate = sub.add_parser("validate", help="Validate a spec against the registry")
|
|
163
|
+
p_validate.add_argument("spec", type=Path)
|
|
164
|
+
|
|
165
|
+
p_diff = sub.add_parser("diff", help="Compare two specs")
|
|
166
|
+
p_diff.add_argument("before", type=Path)
|
|
167
|
+
p_diff.add_argument("after", type=Path)
|
|
168
|
+
|
|
169
|
+
p_to_a2ui = sub.add_parser("to-a2ui", help="Convert GF-Lang to A2UI JSONL")
|
|
170
|
+
p_to_a2ui.add_argument("spec", type=Path)
|
|
171
|
+
p_to_a2ui.add_argument("-o", "--output", type=Path, default=None)
|
|
172
|
+
|
|
173
|
+
p_from_a2ui = sub.add_parser("from-a2ui", help="Convert A2UI JSONL to GF-Lang")
|
|
174
|
+
p_from_a2ui.add_argument("a2ui", type=Path)
|
|
175
|
+
p_from_a2ui.add_argument("-o", "--output", type=Path, default=None)
|
|
176
|
+
|
|
177
|
+
args = parser.parse_args()
|
|
178
|
+
|
|
179
|
+
if args.command == "render":
|
|
180
|
+
text = args.spec.read_text()
|
|
181
|
+
html = render_html(text)
|
|
182
|
+
if args.output:
|
|
183
|
+
args.output.write_text(html)
|
|
184
|
+
print(f"wrote {args.output}")
|
|
185
|
+
else:
|
|
186
|
+
print(html)
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
if args.command == "validate":
|
|
190
|
+
text = args.spec.read_text()
|
|
191
|
+
p = GFLangParser()
|
|
192
|
+
nodes = p.feed_chunk(text)
|
|
193
|
+
r = Registry()
|
|
194
|
+
valid = 0
|
|
195
|
+
invalid = 0
|
|
196
|
+
from .spec.ast import Assignment, Component
|
|
197
|
+
for n in nodes:
|
|
198
|
+
target = n.value if isinstance(n, Assignment) else n
|
|
199
|
+
if isinstance(target, Component):
|
|
200
|
+
if r.has(target.name):
|
|
201
|
+
valid += 1
|
|
202
|
+
print(f" ✓ {target.name}")
|
|
203
|
+
else:
|
|
204
|
+
invalid += 1
|
|
205
|
+
print(f" ✗ {target.name} — not in registry")
|
|
206
|
+
print(f"\n{valid} valid, {invalid} invalid")
|
|
207
|
+
return 0 if invalid == 0 else 1
|
|
208
|
+
|
|
209
|
+
if args.command == "diff":
|
|
210
|
+
before = args.before.read_text()
|
|
211
|
+
after = args.after.read_text()
|
|
212
|
+
entries = diff_specs(before, after)
|
|
213
|
+
summary = summarize_diff(entries)
|
|
214
|
+
print(json.dumps({"summary": summary, "entries": entries}, indent=2))
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
if args.command == "to-a2ui":
|
|
218
|
+
text = args.spec.read_text()
|
|
219
|
+
out = gflang_to_a2ui(text)
|
|
220
|
+
if args.output:
|
|
221
|
+
args.output.write_text(out + "\n")
|
|
222
|
+
print(f"wrote {args.output}")
|
|
223
|
+
else:
|
|
224
|
+
print(out)
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
if args.command == "from-a2ui":
|
|
228
|
+
text = args.a2ui.read_text()
|
|
229
|
+
out = a2ui_to_gflang(text)
|
|
230
|
+
if args.output:
|
|
231
|
+
args.output.write_text(out + "\n")
|
|
232
|
+
print(f"wrote {args.output}")
|
|
233
|
+
else:
|
|
234
|
+
print(out)
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
return 1
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
sys.exit(main())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Databind module: data sources + resolvers."""
|
|
2
|
+
from .config import Action, AppConfig, DataSource
|
|
3
|
+
from .resolver import (
|
|
4
|
+
McpResolver,
|
|
5
|
+
GraphqlResolver,
|
|
6
|
+
Resolver,
|
|
7
|
+
ResolverError,
|
|
8
|
+
RestResolver,
|
|
9
|
+
SqlResolver,
|
|
10
|
+
cache_clear,
|
|
11
|
+
get_resolver,
|
|
12
|
+
register_resolver,
|
|
13
|
+
resolve_source,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Action",
|
|
18
|
+
"AppConfig",
|
|
19
|
+
"DataSource",
|
|
20
|
+
"GraphqlResolver",
|
|
21
|
+
"McpResolver",
|
|
22
|
+
"Resolver",
|
|
23
|
+
"ResolverError",
|
|
24
|
+
"RestResolver",
|
|
25
|
+
"SqlResolver",
|
|
26
|
+
"cache_clear",
|
|
27
|
+
"get_resolver",
|
|
28
|
+
"register_resolver",
|
|
29
|
+
"resolve_source",
|
|
30
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""App-level registry config: data sources + actions, loaded from a YAML file.
|
|
2
|
+
|
|
3
|
+
This is the *application developer's* config — separate from the
|
|
4
|
+
component registry (which is the LLM-facing allow-list of UI primitives).
|
|
5
|
+
|
|
6
|
+
The config binds:
|
|
7
|
+
- `sources`: named data sources (REST, SQL, GraphQL, MCP) — the LLM
|
|
8
|
+
emits `src="q3_revenue"` and the backend resolves it here.
|
|
9
|
+
- `actions`: named intent → endpoint mappings — the LLM emits
|
|
10
|
+
`intent="refund.approve"` and the backend dispatches here.
|
|
11
|
+
|
|
12
|
+
Secrets are referenced via `${SECRET_NAME}` syntax and resolved at
|
|
13
|
+
load time from environment variables. Secrets never appear in prompts
|
|
14
|
+
or logs.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Literal
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_SECRET_RE = re.compile(r"\$\{([A-Z][A-Z0-9_]*)\}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _interpolate(value: Any, vars: dict[str, str]) -> Any:
|
|
31
|
+
"""Replace `{key}` placeholders in any string within a nested structure.
|
|
32
|
+
|
|
33
|
+
Used to inject paths like `{config_dir}` into YAML values so config files
|
|
34
|
+
stay self-contained (no absolute paths leaking into the repo).
|
|
35
|
+
"""
|
|
36
|
+
if isinstance(value, str):
|
|
37
|
+
out = value
|
|
38
|
+
for k, v in vars.items():
|
|
39
|
+
out = out.replace("{" + k + "}", v)
|
|
40
|
+
return out
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
return {k: _interpolate(v, vars) for k, v in value.items()}
|
|
43
|
+
if isinstance(value, list):
|
|
44
|
+
return [_interpolate(v, vars) for v in value]
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_secrets(value: Any) -> Any:
|
|
49
|
+
"""Recursively resolve ${SECRET_NAME} references from environment."""
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
def repl(m: re.Match) -> str:
|
|
52
|
+
name = m.group(1)
|
|
53
|
+
v = os.environ.get(name)
|
|
54
|
+
if v is None:
|
|
55
|
+
raise ValueError(f"Secret {name!r} referenced in config but not set in environment")
|
|
56
|
+
return v
|
|
57
|
+
return _SECRET_RE.sub(repl, value)
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
return {k: _resolve_secrets(v) for k, v in value.items()}
|
|
60
|
+
if isinstance(value, list):
|
|
61
|
+
return [_resolve_secrets(v) for v in value]
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class DataSource:
|
|
67
|
+
name: str
|
|
68
|
+
type: Literal["rest", "sql", "graphql", "mcp", "file"]
|
|
69
|
+
config: dict = field(default_factory=dict)
|
|
70
|
+
cache_seconds: int = 0
|
|
71
|
+
description: str = ""
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_dict(cls, name: str, d: dict) -> "DataSource":
|
|
75
|
+
t = d.get("type")
|
|
76
|
+
if t not in ("rest", "sql", "graphql", "mcp", "file"):
|
|
77
|
+
raise ValueError(f"Source {name!r}: unknown type {t!r}")
|
|
78
|
+
return cls(
|
|
79
|
+
name=name,
|
|
80
|
+
type=t,
|
|
81
|
+
config={k: v for k, v in d.items() if k not in ("type", "cache", "description")},
|
|
82
|
+
cache_seconds=int(d.get("cache", 0)),
|
|
83
|
+
description=d.get("description", ""),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class Action:
|
|
89
|
+
name: str
|
|
90
|
+
type: Literal["rest"] = "rest" # only REST actions for v2; SQL/MCP later
|
|
91
|
+
method: str = "POST"
|
|
92
|
+
url: str = ""
|
|
93
|
+
headers: dict = field(default_factory=dict)
|
|
94
|
+
body_template: dict = field(default_factory=dict)
|
|
95
|
+
bind: list[str] = field(default_factory=list) # keys the LLM may supply
|
|
96
|
+
confirm: bool = True
|
|
97
|
+
audit: bool = True
|
|
98
|
+
requires_role: str | None = None
|
|
99
|
+
description: str = ""
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_dict(cls, name: str, d: dict) -> "Action":
|
|
103
|
+
bind = d.get("bind", [])
|
|
104
|
+
if not isinstance(bind, list):
|
|
105
|
+
raise ValueError(f"Action {name!r}: 'bind' must be a list")
|
|
106
|
+
return cls(
|
|
107
|
+
name=name,
|
|
108
|
+
type="rest",
|
|
109
|
+
method=d.get("method", "POST").upper(),
|
|
110
|
+
url=d.get("url", ""),
|
|
111
|
+
headers=d.get("headers", {}),
|
|
112
|
+
body_template=d.get("body", {}),
|
|
113
|
+
bind=bind,
|
|
114
|
+
confirm=bool(d.get("confirm", True)),
|
|
115
|
+
audit=bool(d.get("audit", True)),
|
|
116
|
+
requires_role=d.get("requires_role"),
|
|
117
|
+
description=d.get("description", ""),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class AppConfig:
|
|
123
|
+
sources: dict[str, DataSource] = field(default_factory=dict)
|
|
124
|
+
actions: dict[str, Action] = field(default_factory=dict)
|
|
125
|
+
version: int = 1
|
|
126
|
+
|
|
127
|
+
def source(self, name: str) -> DataSource | None:
|
|
128
|
+
return self.sources.get(name)
|
|
129
|
+
|
|
130
|
+
def action(self, name: str) -> Action | None:
|
|
131
|
+
return self.actions.get(name)
|
|
132
|
+
|
|
133
|
+
def source_names(self) -> list[str]:
|
|
134
|
+
return sorted(self.sources.keys())
|
|
135
|
+
|
|
136
|
+
def action_names(self) -> list[str]:
|
|
137
|
+
return sorted(self.actions.keys())
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_dict(cls, d: dict) -> "AppConfig":
|
|
141
|
+
d = _resolve_secrets(d)
|
|
142
|
+
sources = {n: DataSource.from_dict(n, s) for n, s in d.get("sources", {}).items()}
|
|
143
|
+
actions = {n: Action.from_dict(n, a) for n, a in d.get("actions", {}).items()}
|
|
144
|
+
return cls(sources=sources, actions=actions, version=d.get("version", 1))
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_file(cls, path: str | Path) -> "AppConfig":
|
|
148
|
+
p = Path(path).resolve()
|
|
149
|
+
data = yaml.safe_load(p.read_text())
|
|
150
|
+
# interpolate {config_dir} in string values so paths stay relative to the YAML file
|
|
151
|
+
config_dir = str(p.parent)
|
|
152
|
+
data = _interpolate(data, {"config_dir": config_dir})
|
|
153
|
+
return cls.from_dict(data)
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def empty(cls) -> "AppConfig":
|
|
157
|
+
return cls()
|
|
158
|
+
|
|
159
|
+
def to_prompt_section(self) -> str:
|
|
160
|
+
"""Render the data sources + actions as sections of the LLM prompt.
|
|
161
|
+
|
|
162
|
+
The LLM only sees intent/source names — never URLs, never secrets.
|
|
163
|
+
This is the security boundary for writes and the grounding layer
|
|
164
|
+
for data.
|
|
165
|
+
"""
|
|
166
|
+
lines: list[str] = []
|
|
167
|
+
if self.sources:
|
|
168
|
+
lines.append("Data sources (use `src=\"name\"` to bind a component to live data):")
|
|
169
|
+
lines.append("")
|
|
170
|
+
for s in self.sources.values():
|
|
171
|
+
desc = f" — {s.description}" if s.description else ""
|
|
172
|
+
lines.append(f"- {s.name} ({s.type}){desc}")
|
|
173
|
+
lines.append("")
|
|
174
|
+
if self.actions:
|
|
175
|
+
lines.append("Available actions (use `intent=\"name\"` on a Button):")
|
|
176
|
+
lines.append("")
|
|
177
|
+
for a in self.actions.values():
|
|
178
|
+
bind = ", ".join(a.bind) if a.bind else "(no args)"
|
|
179
|
+
conf = " [requires confirmation]" if a.confirm else ""
|
|
180
|
+
desc = f" — {a.description}" if a.description else ""
|
|
181
|
+
lines.append(f"- {a.name}({bind}){conf}{desc}")
|
|
182
|
+
lines.append("")
|
|
183
|
+
return "\n".join(lines)
|