Metadata-Version: 2.4
Name: lucid-graphql
Version: 0.5.0
Summary: Turn natural-language questions into valid GraphQL queries with an agentic LLM workflow.
Keywords: graphql,llm,agent,natural-language
Author: Martin Galpin
Author-email: Martin Galpin <galpin@galpin.com>
License-Expression: MIT
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries
Requires-Dist: strands-agents>=1.0
Requires-Dist: graphql-core>=3.2
Requires-Dist: httpx>=0.27
Requires-Dist: click>=8.1
Requires-Dist: strands-agents[anthropic]>=1.0 ; extra == 'anthropic'
Requires-Dist: strands-agents[litellm]>=1.0 ; extra == 'litellm'
Requires-Dist: fastmcp>=2.0 ; extra == 'mcp'
Requires-Python: >=3.13
Project-URL: Homepage, https://github.com/galpin/lucid
Provides-Extra: anthropic
Provides-Extra: litellm
Provides-Extra: mcp
Description-Content-Type: text/markdown

# lucid

**lucid** turns a natural-language question into a valid GraphQL query — and, if you
want, executes it and hands you the data.

```python
import lucid

SpaceX = "https://spacex-production.up.railway.app/"

response = lucid.ask("the 5 latest launches and their rocket names", url=SpaceX)

response.data     # dict — the raw GraphQL `data` payload
response.query    # str  — the generated GraphQL query that produced it
response.errors   # list — GraphQL errors, if any
```

Prefer a natural-language answer to the raw data? Pass `summarize=True` and the
model turns the result into prose on `response.summary` (the data is still
available on `response.data`):

```python
response = lucid.ask("how many launches succeeded in 2020?", url=SpaceX, summarize=True)
response.summary  # str — e.g. "There were 26 launches in 2020, all successful."
```

Query generation is driven by an agentic workflow built on the
[Strands Agents SDK](https://strandsagents.com). The agent authors the query by
navigating the schema through tools — searching it, inspecting individual types,
validating candidates — so the full schema **never enters the model context**. That is
what makes lucid cheap and scalable on very large schemas.

## Install

```sh
uv add lucid-graphql            # or: pip install lucid-graphql
uv add "lucid-graphql[litellm]" # to select models by LiteLLM id string
```

## Command line

Ask an endpoint a question straight from the terminal — no install needed with
`uvx` (the `--from` form pulls the Anthropic provider extra; set
`ANTHROPIC_API_KEY`):

```sh
uvx --from "lucid-graphql[anthropic]" lucid \
  "the 5 latest launches and their rocket names" \
  --url https://spacex-production.up.railway.app/

# or install it as a tool:
uv tool install "lucid-graphql[anthropic]"
lucid "the 5 latest launches and their rocket names" --url https://spacex-production.up.railway.app/
```

The result data prints as JSON on stdout. `--generate/-g` prints the generated
query instead of executing it; `--summarize/-s` prints a natural-language answer
instead of the JSON data; `--verbose/-v` streams the agent's progress (thinking,
tool calls, attempts) to stderr, so stdout stays pipeable:

```sh
lucid "electric SUVs under \$60k" --url $CARS -v | jq '.cars[].model.name'
```

## Usage

Generate a query without executing it:

```python
query: str = lucid.generate("the 5 latest launches and their rocket names", url=SpaceX)
```

Custom HTTP headers (auth tokens, etc.) are honoured on every request lucid makes —
introspection and execution alike:

```python
response = lucid.ask(
    "my recent orders",
    url="https://api.example.com/graphql",
    headers={"Authorization": "Bearer <token>"},
)
```

Create a client that closes over the endpoint and model configuration:

```python
client = lucid.create(
    url=SpaceX,
    headers={"Authorization": "Bearer <token>"},
    model="openrouter/anthropic/claude-3.5-sonnet",
)
client.ask("...")
client.generate("...")
```

### Steering generation with instructions

Applications embedding lucid can append domain guidance to the agent's system
prompt with `instructions=`. It steers *how* queries are written — conventions,
field preferences, limits — while the workflow contract (schema navigation,
mandatory validation) stays intact and cannot be overridden:

```python
client = lucid.create(
    url=API,
    instructions=(
        "Always include the id field on every record.\n"
        "'latest' or 'newest' means orderBy: YEAR_DESC.\n"
        "Never fetch more than 50 items in one request."
    ),
)
```

### MCP server (extendable)

Expose a lucid client to any MCP-speaking assistant with a FastMCP server
(install the extra: `uv add "lucid-graphql[mcp]"`). The server ships a minimal,
well-chosen toolset:

| tool            | what it does                                                          |
| --------------- | -------------------------------------------------------------------- |
| `search_schema` | find types and fields by keyword                                     |
| `get_type`      | the full SDL — with documentation — of one named type               |
| `ask`           | natural language → generated query → executed → data (or, with `summarize=true`, a natural-language answer) |
| `generate`      | natural language → query, not executed (optional; can be turned off) |

```python
import lucid

client = lucid.create(url="https://api.example.com/graphql")
server = lucid.create_mcp_server(
    client,
    instructions="Answer questions about a marketplace of cars and dealerships.",
)
server.run()  # stdio by default; see FastMCP for HTTP/SSE transports
```

The server ships built-in MCP instructions describing the tools; `instructions=`
says what the underlying system is for and is **prepended** to them (shown to
clients on connect). Pass `enable_generate=False` to drop the `generate` tool
(answer questions only, never hand back raw GraphQL).

`create_mcp_server` returns the `FastMCP` instance, so a domain-specific server
can **extend** it — adding its own tools before running it:

```python
server = lucid.create_mcp_server(client, instructions="Our car marketplace.")

@server.tool
def book_test_drive(car_id: str) -> str:
    """Book a test drive for a car."""
    ...

server.run()
```

FastMCP is imported only when you build a server, so the core package stays free
of the dependency.

### Endpoints with introspection disabled

Many production endpoints disable introspection. Supply the schema directly with
`schema=` and introspection is never attempted — SDL text, a `.graphql`/`.gql` file,
a saved introspection-result JSON file, and a built `graphql.GraphQLSchema` are all
accepted and auto-detected:

```python
response = lucid.ask(
    "the 5 latest cars and their manufacturer names",
    url="https://api.example.com/graphql",   # still needed to execute
    schema="schema.graphql",
    headers={"Authorization": "Bearer <token>"},
)

# Generate-only needs no url at all — fully offline.
query = lucid.generate("electric SUVs under $60k", schema="schema.graphql")
```

An explicit `schema` wins over the on-disk cache, which wins over live
introspection. If introspection is attempted and the endpoint refuses it, lucid
raises `SchemaError` with a pointer to `schema=`.

### Watching progress (experimental)

`ask_stream` is `ask` with progress events — same pipeline, instrumented. Each event
has a ready-to-print `message`; kinds are `thinking`, `tool`, `attempt` (one per
validation/execution round, carrying the error that was fed back), and a final
`done` carrying the `Response`. The event schema is a prototype and may change.

```python
for event in lucid.ask_stream("the 5 latest cars and their rocket names", url=Cars):
    print(event.message)
response = event.response  # the final event is kind="done"

# Or skip the loop:
response = lucid.ask_stream(question, url=Cars, on_event=lambda e: print(e.message)).result()
```

`generate_stream` is the same for the generate-only path (works offline with
`schema=`, no `url` needed): its `done` event carries the validated query and
`.result()` returns it as a `str`.

```python
query = lucid.generate_stream(
    "electric SUVs under $60k",
    schema="schema.graphql",
    on_event=lambda e: print(e.message),
).result()
```

## Choosing a model

Provider choice is one line. `model` accepts:

- **Nothing** — defaults to Anthropic Claude (requires `lucid-graphql[anthropic]` and
  `ANTHROPIC_API_KEY`).
- **A string** — treated as a [LiteLLM](https://docs.litellm.ai) model id (requires
  `lucid-graphql[litellm]`); the provider prefix swaps the whole backend:

  | model                                      | reads                |
  | ------------------------------------------ | -------------------- |
  | `"anthropic/claude-3.5-sonnet"`            | `ANTHROPIC_API_KEY`  |
  | `"openai/gpt-4o"`                          | `OPENAI_API_KEY`     |
  | `"openrouter/anthropic/claude-3.5-sonnet"` | `OPENROUTER_API_KEY` |
  | `"ollama/llama3"`                          | local Ollama, no key |

- **Any Strands model instance** — the escape hatch for full control:

  ```python
  import os
  from strands.models.litellm import LiteLLMModel

  model = LiteLLMModel(
      client_args={
          "api_key": os.environ["OPENROUTER_API_KEY"],
          "base_url": "https://openrouter.ai/api/v1",
      },
      model_id="anthropic/claude-3.5-sonnet",
      params={"temperature": 0},
  )
  lucid.ask("...", url=SpaceX, model=model)
  ```

Temperature defaults to 0 for determinism.

## How it works

1. **Introspect (deterministic, no LLM).** The standard introspection query runs once
   per endpoint; the schema is converted to SDL and cached on disk, keyed by a hash of
   the URL (`cache_dir` to relocate, `refresh_schema=True` to re-introspect).
2. **Author (the agent).** A Strands agent explores the schema file through tools —
   `list_root_fields`, `search_schema`, `get_type` — and drafts a query. It never sees
   the whole schema.
3. **Validate.** The agent must pass `validate_query` (graphql-core validation against
   the real schema) before it can finish; validation errors are fed back for
   self-correction.
4. **Execute (`ask` only).** The query runs against the endpoint; execution errors are
   fed back the same way. The loop is hard-capped by `max_iterations` (default 5).

Exhausting the cap raises a typed error carrying the last query and errors:
`QueryValidationError` or `QueryExecutionError`. All lucid failures subclass
`LucidError` — `SchemaError` for introspection/schema problems and `TransportError`
when the endpoint can't be reached (connection refused, timeout) — so callers never
see a raw `httpx` exception.

## Testing without a network

Transport is a two-method seam (`introspect()` / `execute()`). The default is httpx;
tests inject an in-process transport that runs against a local executable schema:

```python
client = lucid.create(url="local://cars", model=model, transport=my_local_transport)
```

See `tests/cars.py` for a complete example — a deliberately large car-marketplace
schema with interfaces, a union, Relay pagination, input filters, and mock data.
The test suite runs every core scenario twice: once through the in-process
transport, and once over real HTTP against an in-process localhost server, so the
default httpx transport (serialization, headers, status handling) is covered too.

## Evaluating models

`evals/` contains an opt-in bake-off harness (real API keys, spends money, never runs
in CI), built on the [Strands Evals SDK](https://github.com/strands-agents/evals):
each question is a `strands_evals.Case`, one `Experiment` runs per model, and a
custom result-equivalence `Evaluator` grades correctness objectively — the generated
query and a hand-written reference execute against the same in-process schema and
their normalized results are compared. Latency, tokens, cost, and correction
iterations are measured over the whole agent trajectory.

```sh
just evals                    # the standard OpenRouter model set (needs OPENROUTER_API_KEY)
just evals "ollama/llama3" 5  # any model list and repeat count

# or drive the runner directly:
uv run python -m evals.runner \
  --models "openrouter/anthropic/claude-3.5-sonnet,openai/gpt-4o,openai/gpt-4o-mini,ollama/llama3" \
  --repeats 5
```

The report prints a per-model table and highlights the Pareto frontier across
accuracy, latency, and cost. `cost/correct` is usually the column to decide by.

## Development

Day-to-day tasks are [Just](https://just.systems) recipes:

```sh
just sync       # uv sync --all-extras
just test       # pytest (pass args through: just test -k schema)
just lint       # ruff check + format check
just typecheck  # ty
just check      # lint + typecheck + test
just build      # wheel + sdist into dist/
just evals      # model bake-off (OpenRouter by default)
```

One live end-to-end test against the SpaceX API is gated behind
`LUCID_LIVE=1` + `ANTHROPIC_API_KEY` and skipped everywhere else.

### Releasing

The version lives in one place (`pyproject.toml`) and is exposed at runtime as
`lucid.__version__`. Cutting a release is one command — it runs the full check
suite, bumps the version ([SemVer](https://semver.org)), stamps the `CHANGELOG`,
tags, publishes to PyPI, and pushes:

```sh
just release patch   # 0.1.0 -> 0.1.1  (bug fixes)
just release minor   # 0.1.0 -> 0.2.0  (backwards-compatible features)
just release major   # 0.1.0 -> 1.0.0  (breaking changes)
```

It needs a clean tree and `UV_PUBLISH_TOKEN` (loaded from `.env`). Publish runs
before the push, so a failed upload leaves nothing pushed to recover from. Move
the changes you're releasing into a `## [Unreleased]` CHANGELOG section first.

## Non-goals (v1)

- Data-frame transformation — that's [pluck](https://github.com/galpin/pluck).
- Mutations and subscriptions.
- CLI or web UI.

## License

MIT
