brickwright 0.1.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.
Files changed (44) hide show
  1. brickwright/__init__.py +5 -0
  2. brickwright/__main__.py +6 -0
  3. brickwright/_build_template.py +25 -0
  4. brickwright/cli.py +446 -0
  5. brickwright/config.py +104 -0
  6. brickwright/driver/__init__.py +7 -0
  7. brickwright/driver/completion.py +89 -0
  8. brickwright/driver/critique.py +133 -0
  9. brickwright/driver/external_critique.py +114 -0
  10. brickwright/driver/gallery.py +289 -0
  11. brickwright/driver/loop.py +377 -0
  12. brickwright/driver/messages.py +168 -0
  13. brickwright/driver/narration.py +320 -0
  14. brickwright/driver/report.py +52 -0
  15. brickwright/driver/tracking.py +217 -0
  16. brickwright/driver/transcript.py +99 -0
  17. brickwright/driver/viewpoint.py +242 -0
  18. brickwright/prompts/__init__.py +3 -0
  19. brickwright/prompts/compilation.py +110 -0
  20. brickwright/prompts/dispatch_guidance.md +6 -0
  21. brickwright/prompts/general_instructions.md +68 -0
  22. brickwright/prompts/internal_agent_instructions.md +29 -0
  23. brickwright/prompts/internal_subagent_instructions.md +8 -0
  24. brickwright/prompts/render_guidance.md +4 -0
  25. brickwright/py.typed +0 -0
  26. brickwright/record.py +620 -0
  27. brickwright/tools/__init__.py +69 -0
  28. brickwright/tools/build_execution.py +113 -0
  29. brickwright/tools/connectivity_diagnostics.py +118 -0
  30. brickwright/tools/editing.py +314 -0
  31. brickwright/tools/exploration.py +85 -0
  32. brickwright/tools/finalization.py +74 -0
  33. brickwright/tools/registry.py +241 -0
  34. brickwright/tools/rendering.py +249 -0
  35. brickwright/tools/schemas.py +203 -0
  36. brickwright/tools/software_rendering.py +780 -0
  37. brickwright/tools/subagents.py +109 -0
  38. brickwright/tools/testing.py +128 -0
  39. brickwright/tools/validation.py +172 -0
  40. brickwright-0.1.0.dist-info/METADATA +89 -0
  41. brickwright-0.1.0.dist-info/RECORD +44 -0
  42. brickwright-0.1.0.dist-info/WHEEL +4 -0
  43. brickwright-0.1.0.dist-info/entry_points.txt +3 -0
  44. brickwright-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,133 @@
1
+ """The visual-critique step: an independent model judges the build against the target.
2
+
3
+ A separate agent first picks a camera angle whose render matches the reference's
4
+ viewpoint (see :mod:`.viewpoint`); a single call then sees the reference beside that
5
+ matched render and reports whether they match and what differs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, ValidationError
14
+
15
+ from ..config import Config
16
+ from ..record import Record
17
+ from .completion import bad_request_error, litellm_complete
18
+ from .messages import image_content_block, request_kwargs
19
+ from .viewpoint import match_viewpoint
20
+
21
+ VIEWPOINT_COMPARE_PROMPT = """\
22
+ You are an independent reviewer judging how faithfully a LEGO build reproduces a
23
+ target object. You are given:
24
+
25
+ 1. The target reference image.
26
+ 2. A single rendered image of the LEGO build, captured from a camera viewpoint chosen
27
+ to match the reference's viewpoint.
28
+ 3. The original text prompt that requested the build.
29
+
30
+ First describe both objects in detail, then enumerate the concrete differences between
31
+ them. Judge only the main object that the prompt refers to -- ignore lighting, shadows,
32
+ background, camera angle, photographic style, and rendering artifacts. Focus on shape,
33
+ proportions, structure, parts and coloring, and any missing or extra features.
34
+
35
+ Reply with a single JSON object with exactly these keys:
36
+ - "differences_description": a string with your detailed descriptions of both objects
37
+ followed by the differences between them.
38
+ - "done": a boolean -- true if the build faithfully matches the reference (only minor
39
+ or cosmetic differences remain), false if it should be revised further.
40
+
41
+ Reply with only the JSON object, no prose around it."""
42
+
43
+ # What the critic is shown as the original prompt when the build had none.
44
+ NO_TEXT_PROMPT = "(no text prompt)"
45
+
46
+
47
+ class CritiqueResult(BaseModel):
48
+ """An independent reviewer's verdict comparing the build to the target image."""
49
+
50
+ model_config = ConfigDict(frozen=True)
51
+
52
+ differences_description: str
53
+ done: bool
54
+
55
+
56
+ def critique(
57
+ record: Record,
58
+ config: Config,
59
+ *,
60
+ complete: Callable[..., Any] | None = None,
61
+ on_response: Callable[[Any], None] | None = None,
62
+ ) -> CritiqueResult | None:
63
+ """Compare the build to the prompt image and return the verdict.
64
+
65
+ The build is shown from a camera angle matched to the reference's viewpoint.
66
+ The result is ``None`` when no comparison could be made: no matched render, or a
67
+ reply that won't parse into a verdict. Raises ``ValueError`` without a prompt
68
+ image.
69
+
70
+ ``on_response`` observes every raw completion response immediately (the
71
+ viewpoint agent's and the critic's), so the caller can fold their usage and cost
72
+ into the run's tracker; it may raise to stop before another paid request.
73
+ """
74
+ reference = record.prompt_image_path
75
+ if reference is None:
76
+ raise ValueError("critique requires a prompt image")
77
+ complete = complete or litellm_complete()
78
+ prompt = record.read_meta().prompt or NO_TEXT_PROMPT
79
+
80
+ result = match_viewpoint(record, config, complete=complete, on_response=on_response)
81
+ if result is None:
82
+ return None # no matched render to compare against
83
+
84
+ content: list[dict[str, Any]] = [
85
+ {"type": "text", "text": f"Original prompt:\n{prompt}"},
86
+ {"type": "text", "text": "Target reference image:"},
87
+ image_content_block(reference),
88
+ {
89
+ "type": "text",
90
+ "text": (
91
+ "The LEGO build, rendered from a viewpoint chosen to match the "
92
+ f"reference (azimuth {result.azimuth:g}, elevation {result.elevation:g}):"
93
+ ),
94
+ },
95
+ image_content_block(record.viewpoint_path),
96
+ ]
97
+ messages = [
98
+ {"role": "system", "content": VIEWPOINT_COMPARE_PROMPT},
99
+ {"role": "user", "content": content},
100
+ ]
101
+ response = _complete(complete, config, messages)
102
+ if on_response is not None:
103
+ on_response(response)
104
+ try:
105
+ return CritiqueResult.model_validate_json(_text_of(response))
106
+ except ValidationError:
107
+ return None
108
+
109
+
110
+ _RESPONSE_FORMATS = (CritiqueResult, {"type": "json_object"})
111
+
112
+
113
+ def _complete(
114
+ complete: Callable[..., Any], config: Config, messages: list[dict[str, Any]]
115
+ ) -> Any:
116
+ """Run one critic completion, weakening response_format until the provider takes it."""
117
+ kwargs = request_kwargs(config, "critique")
118
+
119
+ def ask(**asked: Any) -> Any:
120
+ return complete(messages=messages, **asked, **kwargs)
121
+
122
+ for response_format in _RESPONSE_FORMATS:
123
+ try:
124
+ return ask(response_format=response_format)
125
+ except bad_request_error() as exc:
126
+ if "response_format" not in str(exc):
127
+ raise
128
+ return ask()
129
+
130
+
131
+ def _text_of(response: Any) -> str:
132
+ """The text content of a completion response (``""`` if it has none)."""
133
+ return response.choices[0].message.content or ""
@@ -0,0 +1,114 @@
1
+ """The playbook ``brickwright external critique-instructions`` prints.
2
+
3
+ It has the external agent dispatch two fresh subagents -- one to match the viewpoint
4
+ (rendering through ``brickwright external render-angle``), one to critique -- each
5
+ handed the driver's own system prompt.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..record import Record
11
+ from .critique import NO_TEXT_PROMPT, VIEWPOINT_COMPARE_PROMPT
12
+ from .viewpoint import ANGLE_CONVENTIONS, ORIENTATION_HINT, VIEWPOINT_TASK
13
+
14
+ DEFAULT_CRITIQUE_ROUNDS = 2
15
+
16
+
17
+ def external_viewpoint_prompt(record_id: str, viewpoint_path: str) -> str:
18
+ """The angle-finding subagent's system prompt: the driver's
19
+ :data:`~driver.viewpoint.VIEWPOINT_TASK`, rendering through
20
+ ``brickwright external render-angle``."""
21
+ return f"""\
22
+ {VIEWPOINT_TASK}
23
+
24
+ Render the build from a camera angle by running this command from the repository root:
25
+
26
+ uv run brickwright external render-angle {record_id} --azimuth <A> --elevation <E>
27
+
28
+ {ANGLE_CONVENTIONS}
29
+
30
+ The command renders the build, writes the image to
31
+ {viewpoint_path}
32
+ (overwriting any previous one), and prints that path. Open the PNG, compare its viewpoint
33
+ to the reference, and re-run the command with adjusted angles to get closer.
34
+ {ORIENTATION_HINT} Use as few renders as you need. When the render is seen from essentially the same viewpoint as
35
+ the reference, stop -- your final render must be that matched view, so it is left at the
36
+ path above for the critic to compare against. Then reply with a plain-text message
37
+ reporting the matched azimuth and elevation and confirming the render at that path."""
38
+
39
+
40
+ def external_critique_instructions(
41
+ record: Record, *, rounds: int = DEFAULT_CRITIQUE_ROUNDS
42
+ ) -> str:
43
+ """Assemble the record-specific critique playbook for the external agent.
44
+
45
+ Raises ``ValueError`` when the record has no prompt image, since there is nothing to
46
+ critique the build against -- the external mirror of the driver skipping the loop.
47
+ """
48
+ reference = record.prompt_image_path
49
+ if reference is None:
50
+ raise ValueError("external critique requires a prompt image")
51
+
52
+ record_id = record.id
53
+ reference_path = str(reference.absolute())
54
+ build_path = str(record.build_path.absolute())
55
+ viewpoint_path = str(record.viewpoint_path.absolute())
56
+ prompt = record.read_meta().prompt or NO_TEXT_PROMPT
57
+ viewpoint_prompt = external_viewpoint_prompt(record_id, viewpoint_path)
58
+ # Braces in the JSON schema example are doubled so the f-string emits them literally.
59
+ return f"""\
60
+ # BrickWright visual critique (image prompt)
61
+
62
+ This is the external-agent counterpart of the driver's `viewpoint` visual-critique loop.
63
+ Run it for image-prompt builds once the build validates and renders acceptably, to check
64
+ the build against the reference image and revise it before finalizing.
65
+
66
+ The loop has two steps, and each is run by a **fresh subagent** -- a new agent started
67
+ with no prior context, so it judges independently, exactly as the driver runs each step as
68
+ an independent model call. Dispatch the subagents yourself, and give each one the system
69
+ prompt quoted below **verbatim**. Do not do these steps in your own context.
70
+
71
+ - Record: {record_id}
72
+ - Reference image: {reference_path}
73
+ - build.py: {build_path}
74
+ - Original prompt: {prompt}
75
+
76
+ Repeat the loop below up to {rounds} time(s), or until the critic reports `"done": true`.
77
+
78
+ ## Step A -- match the camera viewpoint (fresh subagent)
79
+
80
+ Dispatch a fresh subagent whose system prompt is exactly:
81
+
82
+ ----------------------------------------------------------------------
83
+ {viewpoint_prompt}
84
+ ----------------------------------------------------------------------
85
+
86
+ Give that subagent this task: read the reference image at {reference_path} and the build
87
+ source at {build_path}, then find the matching camera angle as its system prompt
88
+ instructs. It leaves the matched render at {viewpoint_path} and reports the azimuth and
89
+ elevation it chose.
90
+
91
+ ## Step B -- critique the build (fresh subagent)
92
+
93
+ Dispatch a second fresh subagent whose system prompt is exactly:
94
+
95
+ ----------------------------------------------------------------------
96
+ {VIEWPOINT_COMPARE_PROMPT}
97
+ ----------------------------------------------------------------------
98
+
99
+ Give that subagent this task:
100
+
101
+ - Original prompt: {prompt}
102
+ - Read the target reference image at {reference_path}.
103
+ - Read the LEGO build's matched render at {viewpoint_path}.
104
+
105
+ It must reply with only the JSON object its system prompt describes
106
+ (`{{"differences_description": "...", "done": true|false}}`).
107
+
108
+ ## Step C -- revise
109
+
110
+ Parse the critic's JSON. If `"done"` is true, the build matches the reference well enough:
111
+ stop the loop and finalize. Otherwise edit `build.py` to address the
112
+ `differences_description`, re-run `uv run brickwright external validate {record_id}` until
113
+ it passes, then repeat from Step A. Stop after {rounds} round(s) even if the critic is not
114
+ yet satisfied."""
@@ -0,0 +1,289 @@
1
+ """A catalog of the Narrator's lifecycle, tool, and summary output."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+ from typing import Any
7
+
8
+ from rich.console import Console
9
+
10
+ from ..config import Config, Effort
11
+ from ..record import Timing, Usage
12
+ from ..tools.registry import ToolOutcome, subagent_outcome, validation_outcome
13
+ from ..tools.subagents import SubagentResult
14
+ from ..tools.testing import CheckResult
15
+ from ..tools.validation import BuildDimensions, ValidationReport
16
+ from .narration import Narrator
17
+ from .report import RunReport
18
+
19
+ _SAMPLE_SOURCE = """\
20
+ import bricklayer as bl
21
+
22
+
23
+ def create_build():
24
+ build = bl.LegoBuild()
25
+ base = build.add(bl.brick(4, 2, color=bl.Color.RED), name="base")
26
+ build.stack(bl.brick(4, 2, color=bl.Color.RED), on=base)
27
+ return build
28
+ """
29
+
30
+ _TRACEBACK = """\
31
+ Traceback (most recent call last):
32
+ File "<explore>", line 1, in <module>
33
+ ValueError: no part matches id '9999'
34
+ """
35
+
36
+
37
+ def print_gallery(console: Console | None = None) -> None:
38
+ """Print one example of every Narrator output to ``console``."""
39
+ console = console or Console()
40
+ narrator = Narrator(console)
41
+ record = _sample_record()
42
+ config = _sample_config()
43
+
44
+ _section(console, "lifecycle")
45
+ narrator.run_started(record, config)
46
+ narrator.turn_started(1)
47
+ narrator.model_message(
48
+ "I'll lay a 4×2 footprint and stack two courses of red brick."
49
+ )
50
+ narrator.warn("the cost limit cannot be enforced for this provider")
51
+
52
+ _section(console, "tool calls & results")
53
+ for call in _tool_scenes():
54
+ narrator.tool_called(call.name, call.arguments)
55
+ for outcome in call.outcomes:
56
+ narrator.tool_finished(call.name, outcome)
57
+ print()
58
+
59
+ _section(console, "run summary")
60
+ for result in _summary_scenes():
61
+ narrator.run_finished(record, result)
62
+ print()
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class _Call:
67
+ """One tool's call line plus the result variants worth showing."""
68
+
69
+ name: str
70
+ arguments: dict[str, Any] = field(default_factory=dict)
71
+ outcomes: list[ToolOutcome] = field(default_factory=list)
72
+
73
+
74
+ def _tool_scenes() -> list[_Call]:
75
+ return [
76
+ _Call(
77
+ name="read_build",
78
+ outcomes=[ToolOutcome(_SAMPLE_SOURCE)],
79
+ ),
80
+ _Call(
81
+ name="write_build",
82
+ arguments={"content": _SAMPLE_SOURCE},
83
+ outcomes=[
84
+ ToolOutcome("ok: build.py written"),
85
+ ToolOutcome("write failed: missing argument 'content'", ok=False),
86
+ ],
87
+ ),
88
+ _Call(
89
+ name="insert_build",
90
+ arguments={
91
+ "insert_line": 6,
92
+ "insert_text": " build.stack(bl.tile(4, 2, color=bl.Color.WHITE), on=base)",
93
+ },
94
+ outcomes=[
95
+ ToolOutcome(
96
+ "ok: build.py edited. Edited region:\n"
97
+ "7: build.stack(bl.tile(4, 2, color=bl.Color.WHITE), on=base)"
98
+ ),
99
+ ToolOutcome(
100
+ "insert failed: invalid insert_line 99: must be in 0..12 "
101
+ "(0 inserts at the top of the file; N inserts after line N)",
102
+ ok=False,
103
+ ),
104
+ ],
105
+ ),
106
+ _Call(
107
+ name="edit_build",
108
+ arguments={
109
+ "old_str": "bl.brick(4, 2, color=bl.Color.RED), name=",
110
+ "new_str": "bl.brick(4, 2, color=bl.Color.BLUE), name=",
111
+ },
112
+ outcomes=[
113
+ ToolOutcome("ok: build.py edited"),
114
+ ToolOutcome("edit failed: old_str not found", ok=False),
115
+ ],
116
+ ),
117
+ _Call(
118
+ name="multi_edit_build",
119
+ arguments={
120
+ "edits": [
121
+ {"old_str": "Color.RED", "new_str": "Color.BLUE"},
122
+ {"old_str": "bl.brick(4, 2,", "new_str": "bl.brick(4, 4,"},
123
+ ]
124
+ },
125
+ outcomes=[
126
+ ToolOutcome("ok: applied 2 edits to build.py"),
127
+ ToolOutcome(
128
+ "multi-edit failed (build.py unchanged): "
129
+ "edit 2 of 2: old_str not found",
130
+ ok=False,
131
+ ),
132
+ ],
133
+ ),
134
+ _Call(
135
+ name="validate",
136
+ outcomes=[
137
+ validation_outcome(
138
+ ValidationReport(
139
+ ok=True,
140
+ dimensions=BuildDimensions(width=4, depth=2, height=2.0),
141
+ brick_count=8,
142
+ checks=[
143
+ CheckResult(name="connected", passed=True, detail=""),
144
+ CheckResult(name="no_collisions", passed=True, detail=""),
145
+ ],
146
+ )
147
+ ),
148
+ validation_outcome(
149
+ ValidationReport(
150
+ ok=False,
151
+ dimensions=BuildDimensions(width=4, depth=2, height=2.0),
152
+ brick_count=8,
153
+ checks=[
154
+ CheckResult(name="connected", passed=True, detail=""),
155
+ CheckResult(
156
+ name="no_collisions",
157
+ passed=False,
158
+ detail="2 bricks overlap at (2, 0)",
159
+ ),
160
+ ],
161
+ )
162
+ ),
163
+ validation_outcome(
164
+ ValidationReport.error(
165
+ "Traceback (most recent call last):\n"
166
+ "NameError: name 'Color' is not defined"
167
+ )
168
+ ),
169
+ ],
170
+ ),
171
+ _Call(
172
+ name="explore",
173
+ arguments={"code": "print(bricklayer.search_parts('brick', limit=5))"},
174
+ outcomes=[
175
+ ToolOutcome("Brick 2x2"),
176
+ ToolOutcome("Brick 2x2 (3003)\nBrick 2x4 (3001)\nBrick 1x2 (3004)"),
177
+ ToolOutcome(f"explore error:\n{_TRACEBACK}", ok=False),
178
+ ],
179
+ ),
180
+ _Call(
181
+ name="render",
182
+ outcomes=[
183
+ ToolOutcome(
184
+ "ok: rendered the build to a multi-view contact sheet; "
185
+ "the image follows.",
186
+ image=Path("out/render.png"),
187
+ ),
188
+ ToolOutcome(f"render failed:\n{_TRACEBACK}", ok=False),
189
+ ],
190
+ ),
191
+ _Call(
192
+ name="finalize",
193
+ outcomes=[
194
+ ToolOutcome("ok: build compiled to out/build.ldr", done=True),
195
+ ToolOutcome(f"compile failed:\n{_TRACEBACK}", ok=False, done=True),
196
+ ],
197
+ ),
198
+ _Call(
199
+ name="dispatch_subagents",
200
+ arguments={
201
+ "tasks": [
202
+ {"prompt": "Design the archway using arch parts.", "tier": "smart"},
203
+ {"prompt": "Design the two flanking towers.", "tier": "balanced"},
204
+ ]
205
+ },
206
+ outcomes=[
207
+ subagent_outcome(
208
+ [
209
+ SubagentResult(ok=True, text="Use two 1x6 arch bricks (3455)."),
210
+ SubagentResult(ok=True, text="Stack 2x2 round bricks (3941)."),
211
+ ]
212
+ ),
213
+ subagent_outcome(
214
+ [
215
+ SubagentResult(ok=True, text="Use two 1x6 arch bricks (3455)."),
216
+ SubagentResult(
217
+ ok=False,
218
+ reason="subagent produced no final answer",
219
+ ),
220
+ ]
221
+ ),
222
+ ],
223
+ ),
224
+ ]
225
+
226
+
227
+ def _summary_scenes() -> list[RunReport]:
228
+ usage = Usage(
229
+ prompt_tokens=18_432,
230
+ completion_tokens=2_561,
231
+ total_tokens=20_993,
232
+ cost_usd=0.2317,
233
+ )
234
+ timing = Timing(
235
+ total=42.8, llm=31.2, tools={"validate": 8.1, "render": 2.9}, other=0.6
236
+ )
237
+ return [
238
+ RunReport(
239
+ done=True,
240
+ source_sha256="example-source",
241
+ report=ValidationReport(ok=True, source_sha256="example-source"),
242
+ validations=3,
243
+ turns=11,
244
+ usage=usage,
245
+ timing=timing,
246
+ ),
247
+ RunReport(
248
+ done=True,
249
+ report=ValidationReport(ok=False),
250
+ validations=5,
251
+ turns=14,
252
+ usage=usage,
253
+ timing=timing,
254
+ ),
255
+ RunReport(
256
+ done=False,
257
+ report=ValidationReport(ok=True),
258
+ validations=2,
259
+ turns=30,
260
+ usage=usage,
261
+ timing=timing,
262
+ ),
263
+ ]
264
+
265
+
266
+ def _section(console: Console, title: str) -> None:
267
+ console.print()
268
+ console.rule(f"[bold]{title}", style="blue")
269
+ console.print()
270
+
271
+
272
+ def _sample_record() -> Any:
273
+ """A stand-in record exposing just what the narration panels read."""
274
+ meta = SimpleNamespace(prompt="Build a 4×2 red brick wall, two courses high.")
275
+ return SimpleNamespace(
276
+ id="rec_3f9a2b-red-brick-wall",
277
+ path=Path("records/rec_3f9a2b-red-brick-wall"),
278
+ read_meta=lambda: meta,
279
+ )
280
+
281
+
282
+ def _sample_config() -> Config:
283
+ return Config(
284
+ model="anthropic/claude-opus-4-8",
285
+ effort=Effort.HIGH,
286
+ max_validations=10,
287
+ max_turns=30,
288
+ max_cost=5.0,
289
+ )