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.
@@ -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)