lucid-graphql 0.2.0__tar.gz
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.
- lucid_graphql-0.2.0/PKG-INFO +315 -0
- lucid_graphql-0.2.0/README.md +290 -0
- lucid_graphql-0.2.0/pyproject.toml +80 -0
- lucid_graphql-0.2.0/src/lucid/__init__.py +67 -0
- lucid_graphql-0.2.0/src/lucid/agent.py +302 -0
- lucid_graphql-0.2.0/src/lucid/cli.py +58 -0
- lucid_graphql-0.2.0/src/lucid/client.py +287 -0
- lucid_graphql-0.2.0/src/lucid/errors.py +31 -0
- lucid_graphql-0.2.0/src/lucid/models.py +47 -0
- lucid_graphql-0.2.0/src/lucid/py.typed +0 -0
- lucid_graphql-0.2.0/src/lucid/schema.py +172 -0
- lucid_graphql-0.2.0/src/lucid/tools.py +173 -0
- lucid_graphql-0.2.0/src/lucid/transport.py +75 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lucid-graphql
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Turn natural-language questions into valid GraphQL queries with an agentic LLM workflow.
|
|
5
|
+
Keywords: graphql,llm,agent,natural-language
|
|
6
|
+
Author: Martin Galpin
|
|
7
|
+
Author-email: Martin Galpin <galpin@galpin.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Dist: strands-agents>=1.0
|
|
15
|
+
Requires-Dist: graphql-core>=3.2
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: click>=8.1
|
|
18
|
+
Requires-Dist: strands-agents[anthropic]>=1.0 ; extra == 'anthropic'
|
|
19
|
+
Requires-Dist: strands-agents[litellm]>=1.0 ; extra == 'litellm'
|
|
20
|
+
Requires-Python: >=3.13
|
|
21
|
+
Project-URL: Homepage, https://github.com/galpin/lucid
|
|
22
|
+
Provides-Extra: anthropic
|
|
23
|
+
Provides-Extra: litellm
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# lucid
|
|
27
|
+
|
|
28
|
+
**lucid** turns a natural-language question into a valid GraphQL query — and, if you
|
|
29
|
+
want, executes it and hands you the data.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import lucid
|
|
33
|
+
|
|
34
|
+
SpaceX = "https://spacex-production.up.railway.app/"
|
|
35
|
+
|
|
36
|
+
response = lucid.ask("the 5 latest launches and their rocket names", url=SpaceX)
|
|
37
|
+
|
|
38
|
+
response.data # dict — the raw GraphQL `data` payload
|
|
39
|
+
response.query # str — the generated GraphQL query that produced it
|
|
40
|
+
response.errors # list — GraphQL errors, if any
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Query generation is driven by an agentic workflow built on the
|
|
44
|
+
[Strands Agents SDK](https://strandsagents.com). The agent authors the query by
|
|
45
|
+
navigating the schema through tools — searching it, inspecting individual types,
|
|
46
|
+
validating candidates — so the full schema **never enters the model context**. That is
|
|
47
|
+
what makes lucid cheap and scalable on very large schemas.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
uv add lucid-graphql # or: pip install lucid-graphql
|
|
53
|
+
uv add "lucid-graphql[litellm]" # to select models by LiteLLM id string
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Command line
|
|
57
|
+
|
|
58
|
+
Ask an endpoint a question straight from the terminal — no install needed with
|
|
59
|
+
`uvx` (the `--from` form pulls the Anthropic provider extra; set
|
|
60
|
+
`ANTHROPIC_API_KEY`):
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
uvx --from "lucid-graphql[anthropic]" lucid \
|
|
64
|
+
"the 5 latest launches and their rocket names" \
|
|
65
|
+
--url https://spacex-production.up.railway.app/
|
|
66
|
+
|
|
67
|
+
# or install it as a tool:
|
|
68
|
+
uv tool install "lucid-graphql[anthropic]"
|
|
69
|
+
lucid "the 5 latest launches and their rocket names" --url https://spacex-production.up.railway.app/
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The result data prints as JSON on stdout. `--generate/-g` prints the generated
|
|
73
|
+
query instead of executing it; `--verbose/-v` streams the agent's progress
|
|
74
|
+
(thinking, tool calls, attempts) to stderr, so stdout stays pipeable:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
lucid "electric SUVs under \$60k" --url $CARS -v | jq '.cars[].model.name'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Usage
|
|
81
|
+
|
|
82
|
+
Generate a query without executing it:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
query: str = lucid.generate("the 5 latest launches and their rocket names", url=SpaceX)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Custom HTTP headers (auth tokens, etc.) are honoured on every request lucid makes —
|
|
89
|
+
introspection and execution alike:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
response = lucid.ask(
|
|
93
|
+
"my recent orders",
|
|
94
|
+
url="https://api.example.com/graphql",
|
|
95
|
+
headers={"Authorization": "Bearer <token>"},
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Create a client that closes over the endpoint and model configuration:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
client = lucid.create(
|
|
103
|
+
url=SpaceX,
|
|
104
|
+
headers={"Authorization": "Bearer <token>"},
|
|
105
|
+
model="openrouter/anthropic/claude-3.5-sonnet",
|
|
106
|
+
)
|
|
107
|
+
client.ask("...")
|
|
108
|
+
client.generate("...")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Steering generation with instructions
|
|
112
|
+
|
|
113
|
+
Applications embedding lucid can append domain guidance to the agent's system
|
|
114
|
+
prompt with `instructions=`. It steers *how* queries are written — conventions,
|
|
115
|
+
field preferences, limits — while the workflow contract (schema navigation,
|
|
116
|
+
mandatory validation) stays intact and cannot be overridden:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
client = lucid.create(
|
|
120
|
+
url=API,
|
|
121
|
+
instructions=(
|
|
122
|
+
"Always include the id field on every record.\n"
|
|
123
|
+
"'latest' or 'newest' means orderBy: YEAR_DESC.\n"
|
|
124
|
+
"Never fetch more than 50 items in one request."
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Endpoints with introspection disabled
|
|
130
|
+
|
|
131
|
+
Many production endpoints disable introspection. Supply the schema directly with
|
|
132
|
+
`schema=` and introspection is never attempted — SDL text, a `.graphql`/`.gql` file,
|
|
133
|
+
a saved introspection-result JSON file, and a built `graphql.GraphQLSchema` are all
|
|
134
|
+
accepted and auto-detected:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
response = lucid.ask(
|
|
138
|
+
"the 5 latest cars and their manufacturer names",
|
|
139
|
+
url="https://api.example.com/graphql", # still needed to execute
|
|
140
|
+
schema="schema.graphql",
|
|
141
|
+
headers={"Authorization": "Bearer <token>"},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Generate-only needs no url at all — fully offline.
|
|
145
|
+
query = lucid.generate("electric SUVs under $60k", schema="schema.graphql")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
An explicit `schema` wins over the on-disk cache, which wins over live
|
|
149
|
+
introspection. If introspection is attempted and the endpoint refuses it, lucid
|
|
150
|
+
raises `SchemaError` with a pointer to `schema=`.
|
|
151
|
+
|
|
152
|
+
### Watching progress (experimental)
|
|
153
|
+
|
|
154
|
+
`ask_stream` is `ask` with progress events — same pipeline, instrumented. Each event
|
|
155
|
+
has a ready-to-print `message`; kinds are `thinking`, `tool`, `attempt` (one per
|
|
156
|
+
validation/execution round, carrying the error that was fed back), and a final
|
|
157
|
+
`done` carrying the `Response`. The event schema is a prototype and may change.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
for event in lucid.ask_stream("the 5 latest cars and their rocket names", url=Cars):
|
|
161
|
+
print(event.message)
|
|
162
|
+
response = event.response # the final event is kind="done"
|
|
163
|
+
|
|
164
|
+
# Or skip the loop:
|
|
165
|
+
response = lucid.ask_stream(question, url=Cars, on_event=lambda e: print(e.message)).result()
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`generate_stream` is the same for the generate-only path (works offline with
|
|
169
|
+
`schema=`, no `url` needed): its `done` event carries the validated query and
|
|
170
|
+
`.result()` returns it as a `str`.
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
query = lucid.generate_stream(
|
|
174
|
+
"electric SUVs under $60k",
|
|
175
|
+
schema="schema.graphql",
|
|
176
|
+
on_event=lambda e: print(e.message),
|
|
177
|
+
).result()
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Choosing a model
|
|
181
|
+
|
|
182
|
+
Provider choice is one line. `model` accepts:
|
|
183
|
+
|
|
184
|
+
- **Nothing** — defaults to Anthropic Claude (requires `lucid-graphql[anthropic]` and
|
|
185
|
+
`ANTHROPIC_API_KEY`).
|
|
186
|
+
- **A string** — treated as a [LiteLLM](https://docs.litellm.ai) model id (requires
|
|
187
|
+
`lucid-graphql[litellm]`); the provider prefix swaps the whole backend:
|
|
188
|
+
|
|
189
|
+
| model | reads |
|
|
190
|
+
| ------------------------------------------ | -------------------- |
|
|
191
|
+
| `"anthropic/claude-3.5-sonnet"` | `ANTHROPIC_API_KEY` |
|
|
192
|
+
| `"openai/gpt-4o"` | `OPENAI_API_KEY` |
|
|
193
|
+
| `"openrouter/anthropic/claude-3.5-sonnet"` | `OPENROUTER_API_KEY` |
|
|
194
|
+
| `"ollama/llama3"` | local Ollama, no key |
|
|
195
|
+
|
|
196
|
+
- **Any Strands model instance** — the escape hatch for full control:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
import os
|
|
200
|
+
from strands.models.litellm import LiteLLMModel
|
|
201
|
+
|
|
202
|
+
model = LiteLLMModel(
|
|
203
|
+
client_args={
|
|
204
|
+
"api_key": os.environ["OPENROUTER_API_KEY"],
|
|
205
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
206
|
+
},
|
|
207
|
+
model_id="anthropic/claude-3.5-sonnet",
|
|
208
|
+
params={"temperature": 0},
|
|
209
|
+
)
|
|
210
|
+
lucid.ask("...", url=SpaceX, model=model)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Temperature defaults to 0 for determinism.
|
|
214
|
+
|
|
215
|
+
## How it works
|
|
216
|
+
|
|
217
|
+
1. **Introspect (deterministic, no LLM).** The standard introspection query runs once
|
|
218
|
+
per endpoint; the schema is converted to SDL and cached on disk, keyed by a hash of
|
|
219
|
+
the URL (`cache_dir` to relocate, `refresh_schema=True` to re-introspect).
|
|
220
|
+
2. **Author (the agent).** A Strands agent explores the schema file through tools —
|
|
221
|
+
`list_root_fields`, `search_schema`, `get_type` — and drafts a query. It never sees
|
|
222
|
+
the whole schema.
|
|
223
|
+
3. **Validate.** The agent must pass `validate_query` (graphql-core validation against
|
|
224
|
+
the real schema) before it can finish; validation errors are fed back for
|
|
225
|
+
self-correction.
|
|
226
|
+
4. **Execute (`ask` only).** The query runs against the endpoint; execution errors are
|
|
227
|
+
fed back the same way. The loop is hard-capped by `max_iterations` (default 5).
|
|
228
|
+
|
|
229
|
+
Exhausting the cap raises a typed error carrying the last query and errors:
|
|
230
|
+
`QueryValidationError` or `QueryExecutionError`. All lucid failures subclass
|
|
231
|
+
`LucidError` — `SchemaError` for introspection/schema problems and `TransportError`
|
|
232
|
+
when the endpoint can't be reached (connection refused, timeout) — so callers never
|
|
233
|
+
see a raw `httpx` exception.
|
|
234
|
+
|
|
235
|
+
## Testing without a network
|
|
236
|
+
|
|
237
|
+
Transport is a two-method seam (`introspect()` / `execute()`). The default is httpx;
|
|
238
|
+
tests inject an in-process transport that runs against a local executable schema:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
client = lucid.create(url="local://cars", model=model, transport=my_local_transport)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
See `tests/cars.py` for a complete example — a deliberately large car-marketplace
|
|
245
|
+
schema with interfaces, a union, Relay pagination, input filters, and mock data.
|
|
246
|
+
The test suite runs every core scenario twice: once through the in-process
|
|
247
|
+
transport, and once over real HTTP against an in-process localhost server, so the
|
|
248
|
+
default httpx transport (serialization, headers, status handling) is covered too.
|
|
249
|
+
|
|
250
|
+
## Evaluating models
|
|
251
|
+
|
|
252
|
+
`evals/` contains an opt-in bake-off harness (real API keys, spends money, never runs
|
|
253
|
+
in CI), built on the [Strands Evals SDK](https://github.com/strands-agents/evals):
|
|
254
|
+
each question is a `strands_evals.Case`, one `Experiment` runs per model, and a
|
|
255
|
+
custom result-equivalence `Evaluator` grades correctness objectively — the generated
|
|
256
|
+
query and a hand-written reference execute against the same in-process schema and
|
|
257
|
+
their normalized results are compared. Latency, tokens, cost, and correction
|
|
258
|
+
iterations are measured over the whole agent trajectory.
|
|
259
|
+
|
|
260
|
+
```sh
|
|
261
|
+
just evals # the standard OpenRouter model set (needs OPENROUTER_API_KEY)
|
|
262
|
+
just evals "ollama/llama3" 5 # any model list and repeat count
|
|
263
|
+
|
|
264
|
+
# or drive the runner directly:
|
|
265
|
+
uv run python -m evals.runner \
|
|
266
|
+
--models "openrouter/anthropic/claude-3.5-sonnet,openai/gpt-4o,openai/gpt-4o-mini,ollama/llama3" \
|
|
267
|
+
--repeats 5
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The report prints a per-model table and highlights the Pareto frontier across
|
|
271
|
+
accuracy, latency, and cost. `cost/correct` is usually the column to decide by.
|
|
272
|
+
|
|
273
|
+
## Development
|
|
274
|
+
|
|
275
|
+
Day-to-day tasks are [Just](https://just.systems) recipes:
|
|
276
|
+
|
|
277
|
+
```sh
|
|
278
|
+
just sync # uv sync --all-extras
|
|
279
|
+
just test # pytest (pass args through: just test -k schema)
|
|
280
|
+
just lint # ruff check + format check
|
|
281
|
+
just typecheck # ty
|
|
282
|
+
just check # lint + typecheck + test
|
|
283
|
+
just build # wheel + sdist into dist/
|
|
284
|
+
just evals # model bake-off (OpenRouter by default)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
One live end-to-end test against the SpaceX API is gated behind
|
|
288
|
+
`LUCID_LIVE=1` + `ANTHROPIC_API_KEY` and skipped everywhere else.
|
|
289
|
+
|
|
290
|
+
### Releasing
|
|
291
|
+
|
|
292
|
+
The version lives in one place (`pyproject.toml`) and is exposed at runtime as
|
|
293
|
+
`lucid.__version__`. Cutting a release is one command — it runs the full check
|
|
294
|
+
suite, bumps the version ([SemVer](https://semver.org)), stamps the `CHANGELOG`,
|
|
295
|
+
tags, publishes to PyPI, and pushes:
|
|
296
|
+
|
|
297
|
+
```sh
|
|
298
|
+
just release patch # 0.1.0 -> 0.1.1 (bug fixes)
|
|
299
|
+
just release minor # 0.1.0 -> 0.2.0 (backwards-compatible features)
|
|
300
|
+
just release major # 0.1.0 -> 1.0.0 (breaking changes)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
It needs a clean tree and `UV_PUBLISH_TOKEN` (loaded from `.env`). Publish runs
|
|
304
|
+
before the push, so a failed upload leaves nothing pushed to recover from. Move
|
|
305
|
+
the changes you're releasing into a `## [Unreleased]` CHANGELOG section first.
|
|
306
|
+
|
|
307
|
+
## Non-goals (v1)
|
|
308
|
+
|
|
309
|
+
- Data-frame transformation — that's [pluck](https://github.com/galpin/pluck).
|
|
310
|
+
- Mutations and subscriptions.
|
|
311
|
+
- CLI or web UI.
|
|
312
|
+
|
|
313
|
+
## License
|
|
314
|
+
|
|
315
|
+
MIT
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# lucid
|
|
2
|
+
|
|
3
|
+
**lucid** turns a natural-language question into a valid GraphQL query — and, if you
|
|
4
|
+
want, executes it and hands you the data.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
import lucid
|
|
8
|
+
|
|
9
|
+
SpaceX = "https://spacex-production.up.railway.app/"
|
|
10
|
+
|
|
11
|
+
response = lucid.ask("the 5 latest launches and their rocket names", url=SpaceX)
|
|
12
|
+
|
|
13
|
+
response.data # dict — the raw GraphQL `data` payload
|
|
14
|
+
response.query # str — the generated GraphQL query that produced it
|
|
15
|
+
response.errors # list — GraphQL errors, if any
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Query generation is driven by an agentic workflow built on the
|
|
19
|
+
[Strands Agents SDK](https://strandsagents.com). The agent authors the query by
|
|
20
|
+
navigating the schema through tools — searching it, inspecting individual types,
|
|
21
|
+
validating candidates — so the full schema **never enters the model context**. That is
|
|
22
|
+
what makes lucid cheap and scalable on very large schemas.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
uv add lucid-graphql # or: pip install lucid-graphql
|
|
28
|
+
uv add "lucid-graphql[litellm]" # to select models by LiteLLM id string
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Command line
|
|
32
|
+
|
|
33
|
+
Ask an endpoint a question straight from the terminal — no install needed with
|
|
34
|
+
`uvx` (the `--from` form pulls the Anthropic provider extra; set
|
|
35
|
+
`ANTHROPIC_API_KEY`):
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
uvx --from "lucid-graphql[anthropic]" lucid \
|
|
39
|
+
"the 5 latest launches and their rocket names" \
|
|
40
|
+
--url https://spacex-production.up.railway.app/
|
|
41
|
+
|
|
42
|
+
# or install it as a tool:
|
|
43
|
+
uv tool install "lucid-graphql[anthropic]"
|
|
44
|
+
lucid "the 5 latest launches and their rocket names" --url https://spacex-production.up.railway.app/
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The result data prints as JSON on stdout. `--generate/-g` prints the generated
|
|
48
|
+
query instead of executing it; `--verbose/-v` streams the agent's progress
|
|
49
|
+
(thinking, tool calls, attempts) to stderr, so stdout stays pipeable:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
lucid "electric SUVs under \$60k" --url $CARS -v | jq '.cars[].model.name'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
Generate a query without executing it:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
query: str = lucid.generate("the 5 latest launches and their rocket names", url=SpaceX)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Custom HTTP headers (auth tokens, etc.) are honoured on every request lucid makes —
|
|
64
|
+
introspection and execution alike:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
response = lucid.ask(
|
|
68
|
+
"my recent orders",
|
|
69
|
+
url="https://api.example.com/graphql",
|
|
70
|
+
headers={"Authorization": "Bearer <token>"},
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Create a client that closes over the endpoint and model configuration:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
client = lucid.create(
|
|
78
|
+
url=SpaceX,
|
|
79
|
+
headers={"Authorization": "Bearer <token>"},
|
|
80
|
+
model="openrouter/anthropic/claude-3.5-sonnet",
|
|
81
|
+
)
|
|
82
|
+
client.ask("...")
|
|
83
|
+
client.generate("...")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Steering generation with instructions
|
|
87
|
+
|
|
88
|
+
Applications embedding lucid can append domain guidance to the agent's system
|
|
89
|
+
prompt with `instructions=`. It steers *how* queries are written — conventions,
|
|
90
|
+
field preferences, limits — while the workflow contract (schema navigation,
|
|
91
|
+
mandatory validation) stays intact and cannot be overridden:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
client = lucid.create(
|
|
95
|
+
url=API,
|
|
96
|
+
instructions=(
|
|
97
|
+
"Always include the id field on every record.\n"
|
|
98
|
+
"'latest' or 'newest' means orderBy: YEAR_DESC.\n"
|
|
99
|
+
"Never fetch more than 50 items in one request."
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Endpoints with introspection disabled
|
|
105
|
+
|
|
106
|
+
Many production endpoints disable introspection. Supply the schema directly with
|
|
107
|
+
`schema=` and introspection is never attempted — SDL text, a `.graphql`/`.gql` file,
|
|
108
|
+
a saved introspection-result JSON file, and a built `graphql.GraphQLSchema` are all
|
|
109
|
+
accepted and auto-detected:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
response = lucid.ask(
|
|
113
|
+
"the 5 latest cars and their manufacturer names",
|
|
114
|
+
url="https://api.example.com/graphql", # still needed to execute
|
|
115
|
+
schema="schema.graphql",
|
|
116
|
+
headers={"Authorization": "Bearer <token>"},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Generate-only needs no url at all — fully offline.
|
|
120
|
+
query = lucid.generate("electric SUVs under $60k", schema="schema.graphql")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
An explicit `schema` wins over the on-disk cache, which wins over live
|
|
124
|
+
introspection. If introspection is attempted and the endpoint refuses it, lucid
|
|
125
|
+
raises `SchemaError` with a pointer to `schema=`.
|
|
126
|
+
|
|
127
|
+
### Watching progress (experimental)
|
|
128
|
+
|
|
129
|
+
`ask_stream` is `ask` with progress events — same pipeline, instrumented. Each event
|
|
130
|
+
has a ready-to-print `message`; kinds are `thinking`, `tool`, `attempt` (one per
|
|
131
|
+
validation/execution round, carrying the error that was fed back), and a final
|
|
132
|
+
`done` carrying the `Response`. The event schema is a prototype and may change.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
for event in lucid.ask_stream("the 5 latest cars and their rocket names", url=Cars):
|
|
136
|
+
print(event.message)
|
|
137
|
+
response = event.response # the final event is kind="done"
|
|
138
|
+
|
|
139
|
+
# Or skip the loop:
|
|
140
|
+
response = lucid.ask_stream(question, url=Cars, on_event=lambda e: print(e.message)).result()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`generate_stream` is the same for the generate-only path (works offline with
|
|
144
|
+
`schema=`, no `url` needed): its `done` event carries the validated query and
|
|
145
|
+
`.result()` returns it as a `str`.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
query = lucid.generate_stream(
|
|
149
|
+
"electric SUVs under $60k",
|
|
150
|
+
schema="schema.graphql",
|
|
151
|
+
on_event=lambda e: print(e.message),
|
|
152
|
+
).result()
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Choosing a model
|
|
156
|
+
|
|
157
|
+
Provider choice is one line. `model` accepts:
|
|
158
|
+
|
|
159
|
+
- **Nothing** — defaults to Anthropic Claude (requires `lucid-graphql[anthropic]` and
|
|
160
|
+
`ANTHROPIC_API_KEY`).
|
|
161
|
+
- **A string** — treated as a [LiteLLM](https://docs.litellm.ai) model id (requires
|
|
162
|
+
`lucid-graphql[litellm]`); the provider prefix swaps the whole backend:
|
|
163
|
+
|
|
164
|
+
| model | reads |
|
|
165
|
+
| ------------------------------------------ | -------------------- |
|
|
166
|
+
| `"anthropic/claude-3.5-sonnet"` | `ANTHROPIC_API_KEY` |
|
|
167
|
+
| `"openai/gpt-4o"` | `OPENAI_API_KEY` |
|
|
168
|
+
| `"openrouter/anthropic/claude-3.5-sonnet"` | `OPENROUTER_API_KEY` |
|
|
169
|
+
| `"ollama/llama3"` | local Ollama, no key |
|
|
170
|
+
|
|
171
|
+
- **Any Strands model instance** — the escape hatch for full control:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
import os
|
|
175
|
+
from strands.models.litellm import LiteLLMModel
|
|
176
|
+
|
|
177
|
+
model = LiteLLMModel(
|
|
178
|
+
client_args={
|
|
179
|
+
"api_key": os.environ["OPENROUTER_API_KEY"],
|
|
180
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
181
|
+
},
|
|
182
|
+
model_id="anthropic/claude-3.5-sonnet",
|
|
183
|
+
params={"temperature": 0},
|
|
184
|
+
)
|
|
185
|
+
lucid.ask("...", url=SpaceX, model=model)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Temperature defaults to 0 for determinism.
|
|
189
|
+
|
|
190
|
+
## How it works
|
|
191
|
+
|
|
192
|
+
1. **Introspect (deterministic, no LLM).** The standard introspection query runs once
|
|
193
|
+
per endpoint; the schema is converted to SDL and cached on disk, keyed by a hash of
|
|
194
|
+
the URL (`cache_dir` to relocate, `refresh_schema=True` to re-introspect).
|
|
195
|
+
2. **Author (the agent).** A Strands agent explores the schema file through tools —
|
|
196
|
+
`list_root_fields`, `search_schema`, `get_type` — and drafts a query. It never sees
|
|
197
|
+
the whole schema.
|
|
198
|
+
3. **Validate.** The agent must pass `validate_query` (graphql-core validation against
|
|
199
|
+
the real schema) before it can finish; validation errors are fed back for
|
|
200
|
+
self-correction.
|
|
201
|
+
4. **Execute (`ask` only).** The query runs against the endpoint; execution errors are
|
|
202
|
+
fed back the same way. The loop is hard-capped by `max_iterations` (default 5).
|
|
203
|
+
|
|
204
|
+
Exhausting the cap raises a typed error carrying the last query and errors:
|
|
205
|
+
`QueryValidationError` or `QueryExecutionError`. All lucid failures subclass
|
|
206
|
+
`LucidError` — `SchemaError` for introspection/schema problems and `TransportError`
|
|
207
|
+
when the endpoint can't be reached (connection refused, timeout) — so callers never
|
|
208
|
+
see a raw `httpx` exception.
|
|
209
|
+
|
|
210
|
+
## Testing without a network
|
|
211
|
+
|
|
212
|
+
Transport is a two-method seam (`introspect()` / `execute()`). The default is httpx;
|
|
213
|
+
tests inject an in-process transport that runs against a local executable schema:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
client = lucid.create(url="local://cars", model=model, transport=my_local_transport)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
See `tests/cars.py` for a complete example — a deliberately large car-marketplace
|
|
220
|
+
schema with interfaces, a union, Relay pagination, input filters, and mock data.
|
|
221
|
+
The test suite runs every core scenario twice: once through the in-process
|
|
222
|
+
transport, and once over real HTTP against an in-process localhost server, so the
|
|
223
|
+
default httpx transport (serialization, headers, status handling) is covered too.
|
|
224
|
+
|
|
225
|
+
## Evaluating models
|
|
226
|
+
|
|
227
|
+
`evals/` contains an opt-in bake-off harness (real API keys, spends money, never runs
|
|
228
|
+
in CI), built on the [Strands Evals SDK](https://github.com/strands-agents/evals):
|
|
229
|
+
each question is a `strands_evals.Case`, one `Experiment` runs per model, and a
|
|
230
|
+
custom result-equivalence `Evaluator` grades correctness objectively — the generated
|
|
231
|
+
query and a hand-written reference execute against the same in-process schema and
|
|
232
|
+
their normalized results are compared. Latency, tokens, cost, and correction
|
|
233
|
+
iterations are measured over the whole agent trajectory.
|
|
234
|
+
|
|
235
|
+
```sh
|
|
236
|
+
just evals # the standard OpenRouter model set (needs OPENROUTER_API_KEY)
|
|
237
|
+
just evals "ollama/llama3" 5 # any model list and repeat count
|
|
238
|
+
|
|
239
|
+
# or drive the runner directly:
|
|
240
|
+
uv run python -m evals.runner \
|
|
241
|
+
--models "openrouter/anthropic/claude-3.5-sonnet,openai/gpt-4o,openai/gpt-4o-mini,ollama/llama3" \
|
|
242
|
+
--repeats 5
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The report prints a per-model table and highlights the Pareto frontier across
|
|
246
|
+
accuracy, latency, and cost. `cost/correct` is usually the column to decide by.
|
|
247
|
+
|
|
248
|
+
## Development
|
|
249
|
+
|
|
250
|
+
Day-to-day tasks are [Just](https://just.systems) recipes:
|
|
251
|
+
|
|
252
|
+
```sh
|
|
253
|
+
just sync # uv sync --all-extras
|
|
254
|
+
just test # pytest (pass args through: just test -k schema)
|
|
255
|
+
just lint # ruff check + format check
|
|
256
|
+
just typecheck # ty
|
|
257
|
+
just check # lint + typecheck + test
|
|
258
|
+
just build # wheel + sdist into dist/
|
|
259
|
+
just evals # model bake-off (OpenRouter by default)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
One live end-to-end test against the SpaceX API is gated behind
|
|
263
|
+
`LUCID_LIVE=1` + `ANTHROPIC_API_KEY` and skipped everywhere else.
|
|
264
|
+
|
|
265
|
+
### Releasing
|
|
266
|
+
|
|
267
|
+
The version lives in one place (`pyproject.toml`) and is exposed at runtime as
|
|
268
|
+
`lucid.__version__`. Cutting a release is one command — it runs the full check
|
|
269
|
+
suite, bumps the version ([SemVer](https://semver.org)), stamps the `CHANGELOG`,
|
|
270
|
+
tags, publishes to PyPI, and pushes:
|
|
271
|
+
|
|
272
|
+
```sh
|
|
273
|
+
just release patch # 0.1.0 -> 0.1.1 (bug fixes)
|
|
274
|
+
just release minor # 0.1.0 -> 0.2.0 (backwards-compatible features)
|
|
275
|
+
just release major # 0.1.0 -> 1.0.0 (breaking changes)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
It needs a clean tree and `UV_PUBLISH_TOKEN` (loaded from `.env`). Publish runs
|
|
279
|
+
before the push, so a failed upload leaves nothing pushed to recover from. Move
|
|
280
|
+
the changes you're releasing into a `## [Unreleased]` CHANGELOG section first.
|
|
281
|
+
|
|
282
|
+
## Non-goals (v1)
|
|
283
|
+
|
|
284
|
+
- Data-frame transformation — that's [pluck](https://github.com/galpin/pluck).
|
|
285
|
+
- Mutations and subscriptions.
|
|
286
|
+
- CLI or web UI.
|
|
287
|
+
|
|
288
|
+
## License
|
|
289
|
+
|
|
290
|
+
MIT
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lucid-graphql"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Turn natural-language questions into valid GraphQL queries with an agentic LLM workflow."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Martin Galpin", email = "galpin@galpin.com" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"strands-agents>=1.0",
|
|
13
|
+
"graphql-core>=3.2",
|
|
14
|
+
"httpx>=0.27",
|
|
15
|
+
"click>=8.1",
|
|
16
|
+
]
|
|
17
|
+
keywords = ["graphql", "llm", "agent", "natural-language"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
litellm = [
|
|
28
|
+
"strands-agents[litellm]>=1.0",
|
|
29
|
+
]
|
|
30
|
+
anthropic = [
|
|
31
|
+
"strands-agents[anthropic]>=1.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
lucid = "lucid.cli:main"
|
|
36
|
+
lucid-graphql = "lucid.cli:main"
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/galpin/lucid"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
evals = [
|
|
43
|
+
"strands-agents-evals>=0.1",
|
|
44
|
+
"pyyaml>=6.0",
|
|
45
|
+
]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8.0",
|
|
48
|
+
"respx>=0.22",
|
|
49
|
+
"ruff>=0.9",
|
|
50
|
+
"ty>=0.0.1a15",
|
|
51
|
+
"pre-commit>=4.0",
|
|
52
|
+
{ include-group = "evals" },
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[build-system]
|
|
56
|
+
requires = ["uv_build>=0.9.21,<0.10.0"]
|
|
57
|
+
build-backend = "uv_build"
|
|
58
|
+
|
|
59
|
+
[tool.uv.build-backend]
|
|
60
|
+
module-name = "lucid"
|
|
61
|
+
|
|
62
|
+
[tool.ty.environment]
|
|
63
|
+
python-version = "3.13"
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
line-length = 100
|
|
67
|
+
target-version = "py313"
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint.per-file-ignores]
|
|
73
|
+
"tests/cars.py" = ["E501"] # seed-data tables read better as one row per line
|
|
74
|
+
|
|
75
|
+
[tool.pytest.ini_options]
|
|
76
|
+
testpaths = ["tests"]
|
|
77
|
+
markers = [
|
|
78
|
+
"live: tests that hit live endpoints and real LLMs (deselected by default)",
|
|
79
|
+
]
|
|
80
|
+
addopts = "-m 'not live'"
|