Skip to content

Commit 2d39a56

Browse files
docs: upgrade hello-world to demo structured output
Replaces the no-LLM hello-world in README.md with a version that makes a real LLM call via OpenAIProvider and uses a Pydantic class as the response_schema. The resulting Response.parsed flows through state as a typed Classification instance and drives the conditional edge that routes between research and summarize. Defaults to OpenAI public API (gpt-4o-mini) with env-var config: LLM_BASE_URL, LLM_MODEL, LLM_API_KEY. A trailing line in the README calls out OpenRouter, vLLM, LM Studio, llama.cpp as drop-in swaps via base_url/model. The example also lands as a runnable file at examples/00-hello-world/main.py and is added to the smoke test suite. examples/README.md gets a corresponding entry.
1 parent 03bcf23 commit 2d39a56

4 files changed

Lines changed: 191 additions & 35 deletions

File tree

README.md

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -55,26 +55,27 @@ The OpenTelemetry mapping mandates a private `TracerProvider`. That prevents the
5555

5656
## Hello World
5757

58-
About fifty lines that show the engine in action. Three reducer policies declared on one state class. Routing as a pure function of state, not a hidden state machine. An observer attached at compile time that sees every node boundary the engine emits. No LLM, no API key, no boilerplate. Copy it, run it, watch the events fire. Requires Python 3.12 or later.
58+
About sixty lines that show the engine in action. Three reducer policies declared on one state class. An LLM call that returns a typed object, not a string. Conditional routing as a pure function of state, not a hidden state machine. An observer attached at compile time that sees every node boundary the engine emits. Requires Python 3.12 or later and an OpenAI-compatible endpoint (defaults to OpenAI public API; works against any local server too).
5959

6060
```python
6161
import asyncio
62-
from typing import Annotated
63-
64-
from openarmature.graph import (
65-
END,
66-
GraphBuilder,
67-
NodeEvent,
68-
State,
69-
append,
70-
merge,
71-
)
72-
from pydantic import Field
62+
import os
63+
from collections.abc import Mapping
64+
from typing import Annotated, Any, Literal
65+
66+
from openarmature.graph import END, GraphBuilder, NodeEvent, State, append, merge
67+
from openarmature.llm import OpenAIProvider, UserMessage
68+
from pydantic import BaseModel, Field
69+
70+
71+
class Classification(BaseModel):
72+
intent: Literal["research", "summarize"]
73+
rationale: str
7374

7475

7576
class PipelineState(State):
7677
query: str # last_write_wins (default)
77-
classification: str = "" # last_write_wins
78+
classification: Classification | None = None # last_write_wins
7879
sources: Annotated[list[str], append] = Field( # appends across writes
7980
default_factory=list
8081
)
@@ -83,30 +84,32 @@ class PipelineState(State):
8384
)
8485

8586

86-
async def classify(state: PipelineState) -> dict:
87-
decision = "research" if "?" in state.query else "summarize"
88-
return {
89-
"classification": decision,
90-
"metadata": {"classified_by": "rule"},
91-
}
87+
provider = OpenAIProvider(
88+
base_url=os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1"),
89+
model=os.environ.get("LLM_MODEL", "gpt-4o-mini"),
90+
api_key=os.environ.get("LLM_API_KEY"),
91+
)
92+
93+
94+
async def classify(state: PipelineState) -> Mapping[str, Any]:
95+
response = await provider.complete(
96+
[UserMessage(content=f"Route to 'research' or 'summarize': {state.query!r}")],
97+
response_schema=Classification,
98+
)
99+
return {"classification": response.parsed, "metadata": {"classified_by": "llm"}}
92100

93101

94-
async def research(state: PipelineState) -> dict:
95-
return {
96-
"sources": ["wikipedia", "arxiv"],
97-
"metadata": {"tool": "search"},
98-
}
102+
async def research(state: PipelineState) -> Mapping[str, Any]:
103+
return {"sources": ["wikipedia", "arxiv"], "metadata": {"tool": "search"}}
99104

100105

101-
async def summarize(state: PipelineState) -> dict:
102-
return {
103-
"sources": ["cache"],
104-
"metadata": {"tool": "summarizer"},
105-
}
106+
async def summarize(state: PipelineState) -> Mapping[str, Any]:
107+
return {"sources": ["cache"], "metadata": {"tool": "summarizer"}}
106108

107109

108110
def route(state: PipelineState) -> str:
109-
return state.classification
111+
assert state.classification is not None
112+
return state.classification.intent
110113

111114

112115
async def trace(event: NodeEvent) -> None:
@@ -127,22 +130,25 @@ graph = (
127130
)
128131
graph.attach_observer(trace)
129132

133+
130134
async def main() -> None:
131135
try:
132-
await graph.invoke(PipelineState(query="what is RAG?"))
136+
final = await graph.invoke(PipelineState(query="what is RAG?"))
137+
print(f"\nclassification: {final.classification}")
133138
finally:
134139
await graph.drain()
135140

136141

137142
asyncio.run(main())
138-
# classify: sources=[]
139-
# research: sources=['wikipedia', 'arxiv']
140143
```
141144

142-
A few things to notice in this short example:
145+
Set `LLM_API_KEY=sk-...` and run. To swap providers, point `LLM_BASE_URL` and `LLM_MODEL` at OpenRouter, vLLM, LM Studio, llama.cpp — anything that speaks the OpenAI Chat Completions wire format. The example also lives at [`examples/00-hello-world/main.py`](./examples/00-hello-world/main.py); see [`examples/`](./examples/) for more runnable demos.
146+
147+
A few things to notice:
143148

144149
- **Three reducer policies on one state schema.** `query` and `classification` get the default `last_write_wins`. `sources` is `Annotated[list[str], append]`, so successive writes concatenate. `metadata` is `Annotated[dict[str, str], merge]`, so successive writes shallow-merge. The merge policy lives on the schema, once.
145-
- **Conditional routing as a state function.** `route` reads `state.classification` and returns a node name. The graph engine doesn't care that this happens to be deterministic; it would accept an LLM-driven router with the same shape.
150+
- **Structured output as a typed object.** `provider.complete(..., response_schema=Classification)` returns `Response.parsed` as a validated `Classification` instance, not a string the caller has to JSON-parse and re-validate. Pass a JSON Schema dict instead of a class for the raw form.
151+
- **Conditional routing on a parsed field.** `route` reads `state.classification.intent` and returns the next node's name. The graph engine doesn't care the discriminator came from an LLM; it would accept a deterministic rule with the same shape.
146152
- **Observer sees both phases.** `trace` filters to `completed` events for brevity; the engine also delivers `started` events.
147153
- **The graph either compiles or it doesn't.** Remove `.set_entry()` and `.compile()` raises `NoDeclaredEntry` before `invoke()` runs.
148154

examples/00-hello-world/main.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Hello-world demo: a 3-node graph that classifies a query with an LLM
2+
(via structured output) and routes to one of two follow-up nodes.
3+
4+
**Demonstrates:**
5+
6+
- Typed ``State`` with three reducer policies (``last_write_wins``,
7+
``append``, ``merge``).
8+
- ``OpenAIProvider`` from ``openarmature.llm`` against any
9+
OpenAI-compatible endpoint.
10+
- Structured output via a Pydantic class — the model's response comes
11+
back as a validated ``Classification`` instance, not a string.
12+
- Conditional routing as a pure function of state (``route``).
13+
- ``attach_observer`` for boundary visibility.
14+
15+
**Configuration** (env vars; OpenAI defaults shown):
16+
17+
- ``LLM_BASE_URL`` — defaults to ``https://api.openai.com/v1``.
18+
- ``LLM_MODEL`` — defaults to ``gpt-4o-mini``.
19+
- ``LLM_API_KEY`` — required (your OpenAI API key, or empty for
20+
local servers that don't authenticate).
21+
22+
Run with:
23+
24+
uv sync --group examples
25+
LLM_API_KEY=sk-... uv run python examples/00-hello-world/main.py
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import asyncio
31+
import os
32+
from collections.abc import Mapping
33+
from typing import Annotated, Any, Literal
34+
35+
from pydantic import BaseModel, Field
36+
37+
from openarmature.graph import (
38+
END,
39+
CompiledGraph,
40+
GraphBuilder,
41+
NodeEvent,
42+
State,
43+
append,
44+
merge,
45+
)
46+
from openarmature.llm import OpenAIProvider, UserMessage
47+
48+
49+
class Classification(BaseModel):
50+
"""The Pydantic schema the model is constrained to produce.
51+
52+
Passed as ``response_schema`` to ``provider.complete()``; the
53+
framework converts to JSON Schema, instructs the provider to
54+
return matching content, validates the response, and yields a
55+
``Classification`` instance via ``Response.parsed``.
56+
"""
57+
58+
intent: Literal["research", "summarize"]
59+
rationale: str
60+
61+
62+
class PipelineState(State):
63+
query: str
64+
classification: Classification | None = None
65+
sources: Annotated[list[str], append] = Field(default_factory=list)
66+
metadata: Annotated[dict[str, str], merge] = Field(default_factory=dict)
67+
68+
69+
_provider = OpenAIProvider(
70+
base_url=os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1"),
71+
model=os.environ.get("LLM_MODEL", "gpt-4o-mini"),
72+
api_key=os.environ.get("LLM_API_KEY"),
73+
)
74+
75+
76+
async def classify(state: PipelineState) -> Mapping[str, Any]:
77+
response = await _provider.complete(
78+
[
79+
UserMessage(
80+
content=(
81+
f"Route this query to either 'research' (look something up) or "
82+
f"'summarize' (condense known material): {state.query!r}"
83+
)
84+
)
85+
],
86+
response_schema=Classification,
87+
)
88+
return {"classification": response.parsed, "metadata": {"classified_by": "llm"}}
89+
90+
91+
async def research(state: PipelineState) -> Mapping[str, Any]:
92+
return {"sources": ["wikipedia", "arxiv"], "metadata": {"tool": "search"}}
93+
94+
95+
async def summarize(state: PipelineState) -> Mapping[str, Any]:
96+
return {"sources": ["cache"], "metadata": {"tool": "summarizer"}}
97+
98+
99+
def route(state: PipelineState) -> str:
100+
if state.classification is None:
101+
raise RuntimeError("classify did not populate state.classification")
102+
return state.classification.intent
103+
104+
105+
async def trace(event: NodeEvent) -> None:
106+
if event.phase == "completed" and event.error is None:
107+
print(f"{event.node_name}: sources={event.post_state.sources}")
108+
109+
110+
def build_graph() -> CompiledGraph[PipelineState]:
111+
return (
112+
GraphBuilder(PipelineState)
113+
.add_node("classify", classify)
114+
.add_node("research", research)
115+
.add_node("summarize", summarize)
116+
.add_conditional_edge("classify", route)
117+
.add_edge("research", END)
118+
.add_edge("summarize", END)
119+
.set_entry("classify")
120+
.compile()
121+
)
122+
123+
124+
async def main() -> None:
125+
graph = build_graph()
126+
graph.attach_observer(trace)
127+
try:
128+
final = await graph.invoke(PipelineState(query="what is RAG?"))
129+
print(f"\nclassification: {final.classification}")
130+
print(f"sources: {final.sources}")
131+
print(f"metadata: {final.metadata}")
132+
finally:
133+
await graph.drain()
134+
135+
136+
if __name__ == "__main__":
137+
asyncio.run(main())

examples/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ End-to-end demo projects for `openarmature`. Each is a standalone
66

77
## Demos
88

9+
### [`00-hello-world/`](./00-hello-world/main.py)
10+
11+
Classify a query with an LLM and route to one of two follow-up
12+
nodes. Demonstrates: typed `State` with three reducer policies, the
13+
`OpenAIProvider` from `openarmature.llm`, structured output via a
14+
Pydantic class (`response_schema=Classification``Response.parsed`
15+
as a `Classification` instance), conditional routing on a parsed
16+
field, and a compile-time observer.
17+
18+
Configured via env vars (`LLM_BASE_URL`, `LLM_MODEL`, `LLM_API_KEY`);
19+
defaults to OpenAI public API with `gpt-4o-mini`.
20+
921
### [`01-linear-pipeline/`](./01-linear-pipeline/main.py)
1022

1123
Minimal two-node graph (`plan → write`). Demonstrates: typed `State`,

tests/test_examples_smoke.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
EXAMPLES_DIR = Path(__file__).parent.parent / "examples"
3131

3232
DEMOS = [
33+
"00-hello-world",
3334
"01-linear-pipeline",
3435
"02-routing-and-subgraphs",
3536
"03-explicit-subgraph-mapping",

0 commit comments

Comments
 (0)