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,3 @@
1
+ """generflow-core: Generflow Python backend runtime."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
+ ]
@@ -0,0 +1,186 @@
1
+ """LLM adapter base + OpenAI + Anthropic implementations."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from abc import ABC, abstractmethod
6
+ from typing import AsyncIterator
7
+
8
+
9
+ class LLMError(Exception):
10
+ pass
11
+
12
+
13
+ class LLMAdapter(ABC):
14
+ """Base class. Adapters convert a prompt into a stream of text chunks."""
15
+
16
+ name: str = "base"
17
+
18
+ @abstractmethod
19
+ async def stream(
20
+ self,
21
+ system: str,
22
+ user: str,
23
+ *,
24
+ max_tokens: int = 2000,
25
+ temperature: float = 0.3,
26
+ ) -> AsyncIterator[str]:
27
+ """Yield text chunks as they arrive from the model."""
28
+ if False: # pragma: no cover — abstract
29
+ yield ""
30
+
31
+
32
+ class OpenAIAdapter(LLMAdapter):
33
+ name = "openai"
34
+
35
+ def __init__(self, model: str = "gpt-4o-mini", api_key: str | None = None) -> None:
36
+ self.model = model
37
+ self.api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
38
+ if not self.api_key:
39
+ raise LLMError("OPENAI_API_KEY not set")
40
+
41
+ async def stream(
42
+ self,
43
+ system: str,
44
+ user: str,
45
+ *,
46
+ max_tokens: int = 2000,
47
+ temperature: float = 0.3,
48
+ ) -> AsyncIterator[str]:
49
+ try:
50
+ from openai import AsyncOpenAI
51
+ except ImportError as e:
52
+ raise LLMError("openai package not installed. pip install openai") from e
53
+ client = AsyncOpenAI(api_key=self.api_key)
54
+ resp = await client.chat.completions.create(
55
+ model=self.model,
56
+ messages=[
57
+ {"role": "system", "content": system},
58
+ {"role": "user", "content": user},
59
+ ],
60
+ max_tokens=max_tokens,
61
+ temperature=temperature,
62
+ stream=True,
63
+ )
64
+ async for chunk in resp:
65
+ if chunk.choices and chunk.choices[0].delta.content:
66
+ yield chunk.choices[0].delta.content
67
+
68
+
69
+ class AnthropicAdapter(LLMAdapter):
70
+ name = "anthropic"
71
+
72
+ def __init__(self, model: str = "claude-3-5-sonnet-latest", api_key: str | None = None) -> None:
73
+ self.model = model
74
+ self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
75
+ if not self.api_key:
76
+ raise LLMError("ANTHROPIC_API_KEY not set")
77
+
78
+ async def stream(
79
+ self,
80
+ system: str,
81
+ user: str,
82
+ *,
83
+ max_tokens: int = 2000,
84
+ temperature: float = 0.3,
85
+ ) -> AsyncIterator[str]:
86
+ try:
87
+ from anthropic import AsyncAnthropic
88
+ except ImportError as e:
89
+ raise LLMError("anthropic package not installed. pip install anthropic") from e
90
+ client = AsyncAnthropic(api_key=self.api_key)
91
+ async with client.messages.stream(
92
+ model=self.model,
93
+ system=system,
94
+ messages=[{"role": "user", "content": user}],
95
+ max_tokens=max_tokens,
96
+ temperature=temperature,
97
+ ) as stream:
98
+ async for text in stream.text_stream:
99
+ yield text
100
+
101
+
102
+ class EchoAdapter(LLMAdapter):
103
+ """Returns a hardcoded spec — useful for local dev / tests / offline demo.
104
+
105
+ Picks a spec based on keywords in the user message so the eval harness
106
+ can exercise multiple flows without an API key.
107
+ """
108
+
109
+ name = "echo"
110
+
111
+ DASHBOARD = """root = Card(title="Sales Dashboard", [
112
+ Header(level=1, text="Q3 Performance"),
113
+ Row(gap=md, [
114
+ Metric(label=Revenue, value="$2.4M", delta="+12.4%"),
115
+ Metric(label=Deals, value=142, delta="+8"),
116
+ Metric(label="Win Rate", value="34%", delta="-1.2%"),
117
+ ]),
118
+ Chart(type=bar, title="Monthly Revenue", src="monthly_revenue"),
119
+ Header(level=2, text="Top Customers"),
120
+ List(src="top_customers"),
121
+ ])
122
+ """
123
+
124
+ SIGNUP_FORM = """root = Form(title="Sign up", [
125
+ Field(name=email, label=Email, type=email),
126
+ Field(name=password, label=Password, type=password),
127
+ Button(label="Create account", intent="user.signup"),
128
+ ])
129
+ """
130
+
131
+ TOP_CUSTOMERS = """root = Card(title="Top Customers", [
132
+ Header(level=1, text="By LTV"),
133
+ List(src="top_customers"),
134
+ ])
135
+ """
136
+
137
+ MULTI_SECTION = """root = Card(title="Summary", [
138
+ Header(level=2, text="Overview"),
139
+ Row(gap=md, [
140
+ Metric(label=Revenue, value="$2.4M"),
141
+ Metric(label=Deals, value=142),
142
+ ]),
143
+ ])
144
+ """
145
+
146
+ GENERIC = """root = Card(title="Generated", [
147
+ Text([ "Hello" ]),
148
+ ])
149
+ """
150
+
151
+ def _pick(self, user: str) -> str:
152
+ s = user.lower()
153
+ if "sign up" in s or "signup" in s or "form" in s:
154
+ return self.SIGNUP_FORM
155
+ if "top customer" in s or "ltv" in s:
156
+ return self.TOP_CUSTOMERS
157
+ if "card" in s and "metric" in s:
158
+ return self.MULTI_SECTION
159
+ if "dashboard" in s or "sales" in s:
160
+ return self.DASHBOARD
161
+ return self.GENERIC
162
+
163
+ async def stream(
164
+ self,
165
+ system: str,
166
+ user: str,
167
+ *,
168
+ max_tokens: int = 2000,
169
+ temperature: float = 0.3,
170
+ ) -> AsyncIterator[str]:
171
+ text = self._pick(user)
172
+ # Simulate streaming by yielding character-by-character with small chunks
173
+ chunk_size = 12
174
+ for i in range(0, len(text), chunk_size):
175
+ yield text[i : i + chunk_size]
176
+
177
+
178
+ def get_adapter(name: str | None = None) -> LLMAdapter:
179
+ """Pick an adapter based on env vars. Defaults to Echo if no keys are set."""
180
+ if name == "openai" or (name is None and os.environ.get("OPENAI_API_KEY")):
181
+ if os.environ.get("OPENAI_API_KEY"):
182
+ return OpenAIAdapter()
183
+ if name == "anthropic" or (name is None and os.environ.get("ANTHROPIC_API_KEY")):
184
+ if os.environ.get("ANTHROPIC_API_KEY"):
185
+ return AnthropicAdapter()
186
+ return EchoAdapter()
@@ -0,0 +1,5 @@
1
+ """API module: FastAPI app + SSE streaming + local-only render."""
2
+ from .app import app
3
+ from .prompt import build_system_prompt
4
+
5
+ __all__ = ["app", "build_system_prompt"]