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,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()
|