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,494 @@
|
|
|
1
|
+
"""FastAPI app: SSE streaming + data binding + actions + HITL.
|
|
2
|
+
|
|
3
|
+
Phase 2 wire-up. New endpoints:
|
|
4
|
+
GET /v1/app/manifest — app config (sources + actions, no secrets)
|
|
5
|
+
POST /v1/stream — streaming (now resolves `src=` refs and emits data.fill)
|
|
6
|
+
POST /v1/action/dispatch — build a dispatch plan (returns payload for confirm)
|
|
7
|
+
POST /v1/action/execute — execute a confirmed dispatch plan
|
|
8
|
+
POST /v1/action/audit — recent audit log
|
|
9
|
+
POST /v1/render — local-only mode (no LLM, no data)
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, AsyncIterator
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI, HTTPException
|
|
22
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
23
|
+
from fastapi.responses import StreamingResponse
|
|
24
|
+
from pydantic import BaseModel
|
|
25
|
+
from ..adapters import get_adapter
|
|
26
|
+
from .prompt import build_system_prompt
|
|
27
|
+
from ..actions import (
|
|
28
|
+
ActionError,
|
|
29
|
+
ActionResult,
|
|
30
|
+
DispatchPlan,
|
|
31
|
+
audit_recent,
|
|
32
|
+
build_dispatch,
|
|
33
|
+
execute_dispatch,
|
|
34
|
+
)
|
|
35
|
+
from ..databind import AppConfig, ResolverError, resolve_source
|
|
36
|
+
from ..hitl import Decision, pii_gate, scan_pii
|
|
37
|
+
from ..registry import Registry
|
|
38
|
+
from ..spec import Assignment, Component, GFLangParser, Update, parse_update
|
|
39
|
+
from ..replay import EventBuffer
|
|
40
|
+
from ..observability import get_metrics, M
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
app = FastAPI(title="Generflow", version="0.2.0")
|
|
44
|
+
app.add_middleware(
|
|
45
|
+
CORSMiddleware,
|
|
46
|
+
allow_origins=["*"],
|
|
47
|
+
allow_methods=["*"],
|
|
48
|
+
allow_headers=["*"],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Default registry — components
|
|
52
|
+
_registry = Registry()
|
|
53
|
+
|
|
54
|
+
# Session-scoped event buffers (for replay). Keyed by session_id.
|
|
55
|
+
_session_buffers: dict[str, EventBuffer] = {}
|
|
56
|
+
|
|
57
|
+
# Default app config — empty. Loaded from GENERFLOW_APP_CONFIG env var
|
|
58
|
+
# or default config file if it exists.
|
|
59
|
+
_app_config = AppConfig.empty()
|
|
60
|
+
_default_cfg = Path(__file__).parent.parent.parent.parent / "examples" / "sample-app.yaml"
|
|
61
|
+
if _default_cfg.exists():
|
|
62
|
+
try:
|
|
63
|
+
_app_config = AppConfig.from_file(_default_cfg)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
print(f"warning: could not load {_default_cfg}: {e}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class StreamRequest(BaseModel):
|
|
69
|
+
message: str
|
|
70
|
+
session_id: str | None = None
|
|
71
|
+
adapter: str | None = None
|
|
72
|
+
resolve_data: bool = True # auto-resolve src= refs (toggle off for fast-path testing)
|
|
73
|
+
auto_execute: bool = False # auto-execute confirmed actions (testing only — HITL off)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DispatchRequest(BaseModel):
|
|
77
|
+
intent: str
|
|
78
|
+
bindings: dict[str, Any] = {}
|
|
79
|
+
user: str = "anon"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ExecuteRequest(BaseModel):
|
|
83
|
+
plan: dict[str, Any]
|
|
84
|
+
user: str = "anon"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _sse(event: str, data: dict[str, Any]) -> str:
|
|
88
|
+
return f"event: {event}\ndata: {json.dumps(data, default=str)}\n\n"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _resolve_ref(path: str, ref_name: str, params: dict | None = None) -> dict[str, Any]:
|
|
92
|
+
"""Resolve a `src=$ref_name` reference. Returns a data.fill event payload."""
|
|
93
|
+
try:
|
|
94
|
+
data, fetched_at = await resolve_source(_app_config, ref_name, params or {})
|
|
95
|
+
return {
|
|
96
|
+
"path": path,
|
|
97
|
+
"ref": ref_name,
|
|
98
|
+
"value": data,
|
|
99
|
+
"fetched_at": fetched_at,
|
|
100
|
+
"ok": True,
|
|
101
|
+
}
|
|
102
|
+
except ResolverError as e:
|
|
103
|
+
return {"path": path, "ref": ref_name, "ok": False, "error": str(e)}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _walk_for_refs(node: Any, path: str = "root") -> list[tuple[str, str, dict]]:
|
|
107
|
+
"""Walk a parsed tree, return list of (node_path, ref_name, params)."""
|
|
108
|
+
out: list[tuple[str, str, dict]] = []
|
|
109
|
+
if isinstance(node, Assignment):
|
|
110
|
+
node = node.value
|
|
111
|
+
path = node.identifier or path
|
|
112
|
+
if not isinstance(node, Component):
|
|
113
|
+
return out
|
|
114
|
+
# look at this node's kwargs for src= refs
|
|
115
|
+
for k, v in node.kwargs.items():
|
|
116
|
+
if k == "src" and isinstance(v, Ref):
|
|
117
|
+
out.append((f"{path}.kwargs.src", v.name, {}))
|
|
118
|
+
# recurse into children
|
|
119
|
+
for i, child in enumerate(node.args):
|
|
120
|
+
if isinstance(child, Literal) and isinstance(child.value, list):
|
|
121
|
+
for j, sub in enumerate(child.value):
|
|
122
|
+
out.extend(_walk_for_refs(sub, f"{path}.children[{j}]"))
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def stream_spec(
|
|
127
|
+
message: str,
|
|
128
|
+
session_id: str,
|
|
129
|
+
adapter_name: str | None,
|
|
130
|
+
resolve_data: bool = True,
|
|
131
|
+
) -> AsyncIterator[str]:
|
|
132
|
+
start = time.time()
|
|
133
|
+
metrics = get_metrics()
|
|
134
|
+
metrics.incr(M["REQUESTS"], adapter=adapter_name or "default")
|
|
135
|
+
buf = EventBuffer()
|
|
136
|
+
_session_buffers[session_id] = buf
|
|
137
|
+
|
|
138
|
+
span = metrics.start_span("stream", session_id=session_id, adapter=adapter_name or "default")
|
|
139
|
+
try:
|
|
140
|
+
adapter = get_adapter(adapter_name)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
span.status = "error"
|
|
143
|
+
span.error = str(e)
|
|
144
|
+
metrics.end_span(span, status="error", error=str(e))
|
|
145
|
+
yield _sse("error", {"message": str(e)})
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
yield _sse("session.start", {"session_id": session_id, "adapter": adapter.name})
|
|
149
|
+
buf.append("session.start", {"session_id": session_id, "adapter": adapter.name})
|
|
150
|
+
|
|
151
|
+
parser = GFLangParser()
|
|
152
|
+
system_prompt = build_system_prompt(_registry, _app_config)
|
|
153
|
+
full_text = ""
|
|
154
|
+
spec_line_count = 0
|
|
155
|
+
registered_count = 0
|
|
156
|
+
invalid_count = 0
|
|
157
|
+
update_count = 0
|
|
158
|
+
last_root: Component | None = None
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
llm_span = metrics.start_span("llm_call", adapter=adapter.name)
|
|
162
|
+
async for chunk in adapter.stream(system=system_prompt, user=message):
|
|
163
|
+
full_text += chunk
|
|
164
|
+
nodes = parser.feed_chunk(chunk)
|
|
165
|
+
for node in nodes:
|
|
166
|
+
# Try to interpret as Update first
|
|
167
|
+
if isinstance(node, Assignment) and isinstance(node.value, Component):
|
|
168
|
+
upd = parse_update(f"{node.identifier} = ...", lambda _v: None)
|
|
169
|
+
# parse_update on Assignment path won't work; do manual
|
|
170
|
+
if node.identifier and node.identifier.startswith("Update"):
|
|
171
|
+
# This is an Update — synthesize
|
|
172
|
+
upd = Update(
|
|
173
|
+
path=node.identifier[len("Update("):-1] if "(" in node.identifier else "",
|
|
174
|
+
value=node.value,
|
|
175
|
+
raw_line=full_text.split("\n")[-2] if "\n" in full_text else "",
|
|
176
|
+
)
|
|
177
|
+
if upd.path:
|
|
178
|
+
update_count += 1
|
|
179
|
+
metrics.incr("generflow_updates_total")
|
|
180
|
+
ev_data = {
|
|
181
|
+
"path": upd.path,
|
|
182
|
+
"value": upd.value.to_dict() if hasattr(upd.value, "to_dict") else upd.value,
|
|
183
|
+
}
|
|
184
|
+
yield _sse("update", ev_data)
|
|
185
|
+
buf.append("update", ev_data)
|
|
186
|
+
continue
|
|
187
|
+
if isinstance(node, Assignment):
|
|
188
|
+
comp = node.value
|
|
189
|
+
elif isinstance(node, Component):
|
|
190
|
+
comp = node
|
|
191
|
+
else:
|
|
192
|
+
continue
|
|
193
|
+
if not isinstance(comp, Component):
|
|
194
|
+
continue
|
|
195
|
+
last_root = comp
|
|
196
|
+
spec_line_count += 1
|
|
197
|
+
valid = _registry.has(comp.name)
|
|
198
|
+
if valid:
|
|
199
|
+
registered_count += 1
|
|
200
|
+
metrics.incr(M["COMPONENTS_MOUNTED"])
|
|
201
|
+
else:
|
|
202
|
+
invalid_count += 1
|
|
203
|
+
line_data = {
|
|
204
|
+
"path": f"line:{spec_line_count}",
|
|
205
|
+
"component": comp.name,
|
|
206
|
+
"node": comp.to_dict(),
|
|
207
|
+
"valid": valid,
|
|
208
|
+
}
|
|
209
|
+
yield _sse("spec.line", line_data)
|
|
210
|
+
buf.append("spec.line", line_data)
|
|
211
|
+
if valid:
|
|
212
|
+
mount_data = {
|
|
213
|
+
"path": f"line:{spec_line_count}",
|
|
214
|
+
"component": comp.name,
|
|
215
|
+
"props_hash": hash(json.dumps(comp.kwargs, sort_keys=True, default=str)),
|
|
216
|
+
}
|
|
217
|
+
yield _sse("component.mount", mount_data)
|
|
218
|
+
buf.append("component.mount", mount_data)
|
|
219
|
+
# if has src= ref, resolve and emit data.fill
|
|
220
|
+
if resolve_data:
|
|
221
|
+
for path, ref_name, params in _walk_for_refs(comp):
|
|
222
|
+
if ref_name not in _app_config.source_names():
|
|
223
|
+
hitl_data = {
|
|
224
|
+
"kind": "missing_source",
|
|
225
|
+
"path": path,
|
|
226
|
+
"ref": ref_name,
|
|
227
|
+
"question": f"Source '{ref_name}' is not bound. Skip?",
|
|
228
|
+
"options": ["skip", "abort"],
|
|
229
|
+
}
|
|
230
|
+
yield _sse("hitl.request", hitl_data)
|
|
231
|
+
buf.append("hitl.request", hitl_data)
|
|
232
|
+
metrics.incr(M["HITL_TRIGGERED"], kind="missing_source")
|
|
233
|
+
else:
|
|
234
|
+
data_span = metrics.start_span("data_resolve", ref=ref_name)
|
|
235
|
+
fill = await _resolve_ref(path, ref_name, params)
|
|
236
|
+
metrics.end_span(data_span, status="ok" if fill.get("ok") else "error")
|
|
237
|
+
if fill.get("ok"):
|
|
238
|
+
metrics.incr(M["DATA_RESOLVED"], ref=ref_name)
|
|
239
|
+
val = fill["value"]
|
|
240
|
+
# PII check
|
|
241
|
+
kinds = scan_pii(val)
|
|
242
|
+
if kinds:
|
|
243
|
+
hitl_data = {
|
|
244
|
+
"kind": "pii",
|
|
245
|
+
"path": path,
|
|
246
|
+
"ref": ref_name,
|
|
247
|
+
"reason": f"PII detected: {', '.join(kinds)}",
|
|
248
|
+
"options": ["show redacted", "show full", "skip"],
|
|
249
|
+
"confidence": 0.3,
|
|
250
|
+
}
|
|
251
|
+
yield _sse("hitl.request", hitl_data)
|
|
252
|
+
buf.append("hitl.request", hitl_data)
|
|
253
|
+
metrics.incr(M["HITL_TRIGGERED"], kind="pii")
|
|
254
|
+
else:
|
|
255
|
+
metrics.incr(M["DATA_FAILED"], ref=ref_name)
|
|
256
|
+
yield _sse("data.fill", fill)
|
|
257
|
+
buf.append("data.fill", fill)
|
|
258
|
+
metrics.end_span(llm_span, status="ok")
|
|
259
|
+
# flush
|
|
260
|
+
for node in parser.flush():
|
|
261
|
+
spec_line_count += 1
|
|
262
|
+
except Exception as e:
|
|
263
|
+
span.status = "error"
|
|
264
|
+
span.error = str(e)
|
|
265
|
+
yield _sse("error", {"message": f"stream error: {e}"})
|
|
266
|
+
buf.append("error", {"message": str(e)})
|
|
267
|
+
|
|
268
|
+
latency_ms = int((time.time() - start) * 1000)
|
|
269
|
+
tokens = len(full_text) // 4
|
|
270
|
+
metrics.observe(M["LATENCY_STREAM"], latency_ms, adapter=adapter.name)
|
|
271
|
+
metrics.incr(M["TOKENS"], value=tokens, adapter=adapter.name)
|
|
272
|
+
end_data = {
|
|
273
|
+
"session_id": session_id,
|
|
274
|
+
"tokens": tokens,
|
|
275
|
+
"latency_ms": latency_ms,
|
|
276
|
+
"spec_lines": spec_line_count,
|
|
277
|
+
"registered": registered_count,
|
|
278
|
+
"invalid": invalid_count,
|
|
279
|
+
"updates": update_count,
|
|
280
|
+
"adapter": adapter.name,
|
|
281
|
+
"events": len(buf),
|
|
282
|
+
"text": full_text,
|
|
283
|
+
}
|
|
284
|
+
yield _sse("stream.end", end_data)
|
|
285
|
+
buf.append("stream.end", end_data)
|
|
286
|
+
span.attributes["tokens"] = tokens
|
|
287
|
+
span.attributes["spec_lines"] = spec_line_count
|
|
288
|
+
span.attributes["registered"] = registered_count
|
|
289
|
+
span.attributes["invalid"] = invalid_count
|
|
290
|
+
metrics.end_span(span)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ── Routes ───────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
@app.get("/")
|
|
296
|
+
async def root() -> dict:
|
|
297
|
+
return {
|
|
298
|
+
"name": "generflow-core",
|
|
299
|
+
"version": "0.2.0",
|
|
300
|
+
"registry_components": _registry.names(),
|
|
301
|
+
"app_sources": _app_config.source_names(),
|
|
302
|
+
"app_actions": _app_config.action_names(),
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@app.get("/health")
|
|
307
|
+
async def health() -> dict:
|
|
308
|
+
return {"status": "ok", "version": "0.2.0"}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@app.get("/v1/registry/manifest")
|
|
312
|
+
async def manifest() -> dict:
|
|
313
|
+
return _registry.manifest()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.get("/v1/app/manifest")
|
|
317
|
+
async def app_manifest() -> dict:
|
|
318
|
+
"""App config manifest: sources + actions, no secrets."""
|
|
319
|
+
return {
|
|
320
|
+
"version": _app_config.version,
|
|
321
|
+
"sources": [
|
|
322
|
+
{"name": s.name, "type": s.type, "description": s.description, "cache_seconds": s.cache_seconds}
|
|
323
|
+
for s in _app_config.sources.values()
|
|
324
|
+
],
|
|
325
|
+
"actions": [
|
|
326
|
+
{
|
|
327
|
+
"name": a.name,
|
|
328
|
+
"method": a.method,
|
|
329
|
+
"bind": a.bind,
|
|
330
|
+
"confirm": a.confirm,
|
|
331
|
+
"requires_role": a.requires_role,
|
|
332
|
+
"description": a.description,
|
|
333
|
+
}
|
|
334
|
+
for a in _app_config.actions.values()
|
|
335
|
+
],
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@app.post("/v1/stream")
|
|
340
|
+
async def stream(req: StreamRequest):
|
|
341
|
+
if not req.message.strip():
|
|
342
|
+
raise HTTPException(400, "message is required")
|
|
343
|
+
session_id = req.session_id or str(uuid.uuid4())
|
|
344
|
+
return StreamingResponse(
|
|
345
|
+
stream_spec(req.message, session_id, req.adapter, req.resolve_data),
|
|
346
|
+
media_type="text/event-stream",
|
|
347
|
+
headers={
|
|
348
|
+
"Cache-Control": "no-cache",
|
|
349
|
+
"X-Accel-Buffering": "no",
|
|
350
|
+
"Connection": "keep-alive",
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@app.post("/v1/action/dispatch")
|
|
356
|
+
async def action_dispatch(req: DispatchRequest) -> dict:
|
|
357
|
+
"""Build a dispatch plan from an intent + bindings. Does NOT execute."""
|
|
358
|
+
try:
|
|
359
|
+
plan = build_dispatch(_app_config, req.intent, req.bindings)
|
|
360
|
+
except ActionError as e:
|
|
361
|
+
raise HTTPException(400, str(e))
|
|
362
|
+
return {"plan": plan.to_dict()}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@app.post("/v1/action/execute")
|
|
366
|
+
async def action_execute(req: ExecuteRequest) -> dict:
|
|
367
|
+
"""Execute a previously-built dispatch plan. Caller must have shown the
|
|
368
|
+
user the plan and received approval. This endpoint is idempotent on
|
|
369
|
+
payload_hash within a short window (dedup is the caller's job)."""
|
|
370
|
+
p = req.plan
|
|
371
|
+
# rebuild a thin DispatchPlan from the wire shape
|
|
372
|
+
action = _app_config.action(p["intent"])
|
|
373
|
+
if action is None:
|
|
374
|
+
raise HTTPException(400, f"Unknown action: {p['intent']}")
|
|
375
|
+
plan = DispatchPlan(
|
|
376
|
+
intent=p["intent"],
|
|
377
|
+
action=action,
|
|
378
|
+
method=p["method"],
|
|
379
|
+
url=p["url"],
|
|
380
|
+
headers=p.get("headers", {}),
|
|
381
|
+
body=p.get("body", {}),
|
|
382
|
+
payload_hash=p.get("payload_hash", ""),
|
|
383
|
+
confirm_required=p.get("confirm_required", True),
|
|
384
|
+
requires_role=p.get("requires_role"),
|
|
385
|
+
audit=p.get("audit", True),
|
|
386
|
+
)
|
|
387
|
+
# role check (caller passes user, this is a stub — real impl would
|
|
388
|
+
# check against a session/role store)
|
|
389
|
+
if plan.requires_role and req.user == "anon":
|
|
390
|
+
raise HTTPException(403, f"Action requires role: {plan.requires_role}")
|
|
391
|
+
t0 = time.time()
|
|
392
|
+
result = await execute_dispatch(plan)
|
|
393
|
+
latency_ms = int((time.time() - t0) * 1000)
|
|
394
|
+
if plan.audit:
|
|
395
|
+
from ..actions import audit_record
|
|
396
|
+
audit_record(
|
|
397
|
+
intent=plan.intent,
|
|
398
|
+
user=req.user,
|
|
399
|
+
payload_hash=plan.payload_hash,
|
|
400
|
+
status=result.status,
|
|
401
|
+
latency_ms=latency_ms,
|
|
402
|
+
)
|
|
403
|
+
return {"result": result.to_dict(), "latency_ms": latency_ms}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@app.get("/v1/action/audit")
|
|
407
|
+
async def action_audit(limit: int = 50) -> dict:
|
|
408
|
+
return {"entries": audit_recent(limit)}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ── Phase 3 endpoints ────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
@app.get("/v1/metrics")
|
|
414
|
+
async def metrics() -> "Response":
|
|
415
|
+
"""Prometheus-format metrics."""
|
|
416
|
+
from fastapi import Response
|
|
417
|
+
return Response(content=get_metrics().to_prometheus(), media_type="text/plain")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@app.get("/v1/trace")
|
|
421
|
+
async def trace(limit: int = 50) -> dict:
|
|
422
|
+
"""Recent spans + counters."""
|
|
423
|
+
return get_metrics().to_json()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@app.get("/v1/replay/{session_id}")
|
|
427
|
+
async def replay_session(session_id: str) -> dict:
|
|
428
|
+
"""Replay events from a session buffer (for debugging).
|
|
429
|
+
|
|
430
|
+
Returns events with their original timestamps. For live replay
|
|
431
|
+
with rate, use the WebSocket endpoint (Phase 3.5).
|
|
432
|
+
"""
|
|
433
|
+
buf = _session_buffers.get(session_id)
|
|
434
|
+
if buf is None:
|
|
435
|
+
raise HTTPException(404, f"session {session_id} not found")
|
|
436
|
+
return {
|
|
437
|
+
"session_id": session_id,
|
|
438
|
+
"event_count": len(buf),
|
|
439
|
+
"events": [
|
|
440
|
+
{"seq": e.seq, "ts": e.ts, "type": e.type, "data": e.data}
|
|
441
|
+
for e in buf
|
|
442
|
+
],
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@app.get("/v1/replay/{session_id}/scrub/{seq}")
|
|
447
|
+
async def scrub_session(session_id: str, seq: int) -> dict:
|
|
448
|
+
"""Reconstruct spec state at a given event sequence point."""
|
|
449
|
+
buf = _session_buffers.get(session_id)
|
|
450
|
+
if buf is None:
|
|
451
|
+
raise HTTPException(404, f"session {session_id} not found")
|
|
452
|
+
return buf.scrub_state(seq)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@app.post("/v1/diff")
|
|
456
|
+
async def diff_specs_endpoint(req: StreamRequest) -> dict:
|
|
457
|
+
"""Diff two specs (uses the first line of `message` as `before`, rest as `after`)."""
|
|
458
|
+
from ..spec import diff_specs, summarize_diff
|
|
459
|
+
parts = req.message.split("---SEPARATOR---", 1)
|
|
460
|
+
if len(parts) != 2:
|
|
461
|
+
# fallback: diff against an empty spec
|
|
462
|
+
before, after = "", req.message
|
|
463
|
+
else:
|
|
464
|
+
before, after = parts
|
|
465
|
+
entries = diff_specs(before, after)
|
|
466
|
+
return {"summary": summarize_diff(entries), "entries": entries}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@app.post("/v1/render")
|
|
470
|
+
async def render_static(req: StreamRequest):
|
|
471
|
+
"""Local-only mode: parse a static GF-Lang spec, no LLM call, no data."""
|
|
472
|
+
parser = GFLangParser()
|
|
473
|
+
nodes = parser.feed_chunk(req.message)
|
|
474
|
+
out = []
|
|
475
|
+
for node in nodes:
|
|
476
|
+
out.append(node.to_dict())
|
|
477
|
+
return {"nodes": out, "registry": _registry.names()}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@app.post("/v1/render-html")
|
|
481
|
+
async def render_html_endpoint(req: StreamRequest):
|
|
482
|
+
"""Local-only mode: render a GF-Lang spec to self-contained HTML."""
|
|
483
|
+
from ..cli import render_html
|
|
484
|
+
html = render_html(req.message, _registry)
|
|
485
|
+
return {"html": html, "bytes": len(html)}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def main() -> None:
|
|
489
|
+
import uvicorn
|
|
490
|
+
uvicorn.run(app, host="0.0.0.0", port=7878)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
if __name__ == "__main__":
|
|
494
|
+
main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""System prompt for the LLM.
|
|
2
|
+
|
|
3
|
+
Phase 2: includes data sources + actions sections, plus rules for
|
|
4
|
+
surgical updates on multi-turn conversations.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ..databind import AppConfig
|
|
9
|
+
from ..registry import Registry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SYSTEM_PROMPT_TEMPLATE = """You are Generflow, an AI that composes user interfaces by emitting GF-Lang specifications.
|
|
13
|
+
|
|
14
|
+
{registry}
|
|
15
|
+
|
|
16
|
+
{app}
|
|
17
|
+
|
|
18
|
+
# GF-Lang syntax (line-oriented)
|
|
19
|
+
|
|
20
|
+
You emit one logical line per UI node. Each line is independently renderable.
|
|
21
|
+
|
|
22
|
+
Structure:
|
|
23
|
+
Component(prop1=value, prop2=value, [ child1, child2 ])
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
Header(level=1, text="Q3 Sales")
|
|
27
|
+
Metric(label=Revenue, value="$2.4M", delta="+12.4%")
|
|
28
|
+
Card(p=md, title=Dashboard, [ Header(text=Sales), Row([ Metric(label=A, value=100) ]) ])
|
|
29
|
+
|
|
30
|
+
Rules:
|
|
31
|
+
1. ONLY use the component names listed above. Unknown components will be silently dropped.
|
|
32
|
+
2. ONLY use the props listed for each component. Unknown props are ignored.
|
|
33
|
+
3. Children are always a list `[ ... ]` as the LAST positional argument. Components with `has_children: true` accept a child list.
|
|
34
|
+
4. Strings: use bare identifier for short values (level=1, color=muted) and double-quoted for anything with spaces or special chars (title="My Dashboard").
|
|
35
|
+
5. Numbers are bare (`value=42`), booleans are `true`/`false`, lists use `[ ... ]`.
|
|
36
|
+
6. To bind a component to live data, use `src=source_name` (defined in the data sources above). NEVER invent data values — bind via src.
|
|
37
|
+
7. To make a Button perform an action, use `intent=intent_name` (defined in the actions above). Pass form state via `bind=[field1, field2]` in a Submit wrapper.
|
|
38
|
+
8. Emit one root component named `root = ...` (or just the bare top-level component, treated as root).
|
|
39
|
+
9. NEVER write prose, explanations, or markdown. Output ONLY GF-Lang.
|
|
40
|
+
10. NEVER wrap in code fences.
|
|
41
|
+
11. If the user's request is unclear, ask a clarifying question as plain text (this is the only exception to rule 9).
|
|
42
|
+
|
|
43
|
+
# Multi-turn updates
|
|
44
|
+
|
|
45
|
+
When the user asks to change a small detail on an EXISTING UI (e.g., "change the title to 'Q4 Sales'"), DO NOT regenerate the whole spec. Instead emit an Update:
|
|
46
|
+
|
|
47
|
+
Update(path="root.children[0].kwargs.text", value="Q4 Sales")
|
|
48
|
+
|
|
49
|
+
Path syntax: dot-separated, with `[index]` for children. Common paths:
|
|
50
|
+
- `root.children[0].kwargs.title` — the title prop of the first child
|
|
51
|
+
- `root.children[2].children[1].kwargs.value` — nested
|
|
52
|
+
- `root.kwargs.title` — the root's own prop
|
|
53
|
+
|
|
54
|
+
Surgical updates are ~10x cheaper than full re-renders. Use them whenever possible.
|
|
55
|
+
|
|
56
|
+
# Streaming
|
|
57
|
+
|
|
58
|
+
Emit the root first, then fill in children. The renderer progressively displays what it has, so the user sees structure before data.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_system_prompt(registry: Registry, app: AppConfig | None = None) -> str:
|
|
63
|
+
app_section = app.to_prompt_section() if app else ""
|
|
64
|
+
return SYSTEM_PROMPT_TEMPLATE.format(registry=registry.to_prompt(), app=app_section)
|