weco 0.1.10__py3-none-any.whl → 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.
weco/panels.py ADDED
@@ -0,0 +1,359 @@
1
+ from rich.tree import Tree
2
+ from rich.table import Table
3
+ from rich.progress import BarColumn, Progress, TextColumn
4
+ from rich.layout import Layout
5
+ from rich.panel import Panel
6
+ from rich.syntax import Syntax
7
+ from typing import Dict, List, Optional, Union, Tuple
8
+ from .utils import format_number, truncate_text
9
+
10
+
11
+ class SummaryPanel:
12
+ """Holds a summary of the optimization session."""
13
+
14
+ def __init__(self, total_steps: int, model: str, session_id: str = None):
15
+ self.total_input_tokens = 0
16
+ self.total_output_tokens = 0
17
+ self.total_steps = total_steps
18
+ self.model = model
19
+ self.session_id = session_id or "unknown"
20
+ self.progress = Progress(
21
+ TextColumn("[progress.description]{task.description}"),
22
+ BarColumn(bar_width=20),
23
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
24
+ TextColumn("•"),
25
+ TextColumn("[bold]{task.completed}/{task.total} Steps"),
26
+ expand=False,
27
+ )
28
+ self.task_id = self.progress.add_task("", total=total_steps)
29
+
30
+ def set_step(self, step: int):
31
+ """Set the current step."""
32
+ self.progress.update(self.task_id, completed=step)
33
+
34
+ def update_token_counts(self, usage: Dict[str, int]):
35
+ """Update token counts from usage data."""
36
+ if not isinstance(usage, dict) or "input_tokens" not in usage or "output_tokens" not in usage:
37
+ raise ValueError("Invalid token usage response from API.")
38
+ self.total_input_tokens += usage["input_tokens"]
39
+ self.total_output_tokens += usage["output_tokens"]
40
+
41
+ def get_display(self) -> Panel:
42
+ """Create a summary panel with the relevant information."""
43
+ layout = Layout(name="summary")
44
+ summary_table = Table(show_header=False, box=None, padding=(0, 1))
45
+ # Log directory
46
+ runs_dir = f".runs/{self.session_id}"
47
+ summary_table.add_row(f"[bold cyan]Logs:[/] [blue]{runs_dir}[/]")
48
+ summary_table.add_row("")
49
+ # Model used
50
+ summary_table.add_row(f"[bold cyan]Model:[/] {self.model}")
51
+ summary_table.add_row("")
52
+ # Token counts
53
+ summary_table.add_row(
54
+ f"[bold cyan]Tokens:[/] ↑[yellow]{format_number(self.total_input_tokens)}[/] ↓[yellow]{format_number(self.total_output_tokens)}[/] = [green]{format_number(self.total_input_tokens + self.total_output_tokens)}[/]"
55
+ )
56
+ # Progress bar
57
+ summary_table.add_row(self.progress)
58
+
59
+ # Update layout
60
+ layout.update(summary_table)
61
+
62
+ return Panel(layout, title="[bold]📊 Summary", border_style="magenta", expand=True, padding=(0, 1))
63
+
64
+
65
+ class PlanPanel:
66
+ """Displays the optimization plan with truncation for long plans."""
67
+
68
+ def __init__(self):
69
+ self.plan = ""
70
+ self.max_lines = 3 # Approximate number of lines that can fit in the panel
71
+
72
+ def update(self, plan: str):
73
+ """Update the plan text."""
74
+ self.plan = plan
75
+
76
+ def clear(self):
77
+ """Clear the plan text."""
78
+ self.plan = ""
79
+
80
+ def get_display(self) -> Panel:
81
+ """Create a panel displaying the plan with truncation if needed."""
82
+ display_text = truncate_text(text=self.plan, max_lines=self.max_lines)
83
+ return Panel(
84
+ display_text,
85
+ title="[bold]📝 Thinking...",
86
+ border_style="cyan",
87
+ expand=True,
88
+ padding=(0, 1),
89
+ subtitle="[cyan]↓ truncated ↓[/]" if len(self.plan) > self.max_lines else None,
90
+ )
91
+
92
+
93
+ class Node:
94
+ """Represents a node in the solution tree."""
95
+
96
+ def __init__(
97
+ self, id: str, parent_id: Union[str, None], code: Union[str, None], metric: Union[float, None], is_buggy: bool
98
+ ):
99
+ self.id = id
100
+ self.parent_id = parent_id
101
+ self.children: List["Node"] = []
102
+ self.code = code
103
+ self.metric = metric
104
+ self.is_buggy = is_buggy
105
+ self.evaluated = True
106
+
107
+
108
+ class MetricTree:
109
+ """Manages the tree structure of optimization solutions."""
110
+
111
+ def __init__(self, maximize: bool):
112
+ self.nodes: Dict[str, Node] = {}
113
+ self.maximize = maximize
114
+
115
+ def clear(self):
116
+ """Clear the tree."""
117
+ self.nodes = {}
118
+
119
+ def add_node(self, node: Node):
120
+ """Add a node to the tree."""
121
+ # Add the node to the tree
122
+ self.nodes[node.id] = node
123
+
124
+ # Add node to node's parent's children
125
+ if node.parent_id is not None:
126
+ if node.parent_id not in self.nodes:
127
+ raise ValueError("Could not construct tree: parent node not found.")
128
+ self.nodes[node.parent_id].children.append(node)
129
+
130
+ def get_draft_nodes(self) -> List[Node]:
131
+ """Get all draft nodes from the tree."""
132
+ return [node for node in self.nodes.values() if node.parent_id is None]
133
+
134
+ def get_best_node(self) -> Optional[Node]:
135
+ """Get the best node from the tree."""
136
+ measured_nodes = [
137
+ node
138
+ for node in self.nodes.values()
139
+ if node.evaluated # evaluated
140
+ and not node.is_buggy # not buggy
141
+ and node.metric is not None # has metric
142
+ ]
143
+ if len(measured_nodes) == 0:
144
+ return None
145
+ if self.maximize:
146
+ return max(measured_nodes, key=lambda node: node.metric)
147
+ else:
148
+ return min(measured_nodes, key=lambda node: node.metric)
149
+
150
+
151
+ class MetricTreePanel:
152
+ """Displays the solution tree with depth limiting."""
153
+
154
+ def __init__(self, maximize: bool):
155
+ self.metric_tree = MetricTree(maximize=maximize)
156
+
157
+ def build_metric_tree(self, nodes: List[dict]):
158
+ """Build the tree from the list of nodes."""
159
+ # First clear then tree
160
+ self.metric_tree.clear()
161
+
162
+ # Then sort the nodes by step number
163
+ nodes.sort(key=lambda x: x["step"])
164
+
165
+ # Finally build the new tree
166
+ for node in nodes:
167
+ self.metric_tree.add_node(
168
+ Node(
169
+ id=node["solution_id"],
170
+ parent_id=node["parent_id"],
171
+ code=node["code"],
172
+ metric=node["metric_value"],
173
+ is_buggy=node["is_buggy"],
174
+ )
175
+ )
176
+
177
+ def set_unevaluated_node(self, node_id: str):
178
+ """Set the unevaluated node."""
179
+ self.metric_tree.nodes[node_id].evaluated = False
180
+
181
+ def _build_rich_tree(self) -> Tree:
182
+ """Get a Rich Tree representation of the solution tree using a DFS like traversal."""
183
+ if len(self.metric_tree.nodes) == 0:
184
+ return Tree("[bold green]Building 🌳")
185
+
186
+ best_node = self.metric_tree.get_best_node()
187
+
188
+ def append_rec(node: Node, tree: Tree):
189
+ if not node.evaluated:
190
+ # not evaluated
191
+ color = "yellow"
192
+ style = None
193
+ text = "evaluating..."
194
+ elif node.is_buggy:
195
+ # buggy node
196
+ color = "red"
197
+ style = None
198
+ text = "bug"
199
+ else:
200
+ # evaluated non-buggy node
201
+ if node.id == best_node.id:
202
+ # best node
203
+ color = "green"
204
+ style = "bold"
205
+ text = f"{node.metric:.3f} (best)"
206
+ elif node.metric is None:
207
+ # metric not extracted from evaluated solution
208
+ color = "yellow"
209
+ style = None
210
+ text = "N/A"
211
+ else:
212
+ # evaluated node with metric
213
+ color = "green"
214
+ style = None
215
+ text = f"{node.metric:.3f}"
216
+
217
+ s = f"[{f'{style} ' if style is not None else ''}{color}]● {text}"
218
+ subtree = tree.add(s)
219
+ for child in node.children:
220
+ append_rec(child, subtree)
221
+
222
+ tree = Tree("🌳")
223
+ for n in self.metric_tree.get_draft_nodes():
224
+ append_rec(n, tree)
225
+
226
+ return tree
227
+
228
+ def get_display(self) -> Panel:
229
+ """Get a panel displaying the solution tree."""
230
+ # Make sure the metric tree is built before calling build_rich_tree
231
+ return Panel(
232
+ self._build_rich_tree(), title="[bold]🌳 Solution Tree", border_style="green", expand=True, padding=(0, 1)
233
+ )
234
+
235
+
236
+ class EvaluationOutputPanel:
237
+ """Displays evaluation output with truncation for long outputs."""
238
+
239
+ def __init__(self):
240
+ self.output = ""
241
+ self.max_lines = 25 # Approximate number of lines that can fit in the panel
242
+
243
+ def update(self, output: str) -> None:
244
+ """Update the evaluation output."""
245
+ self.output = output
246
+
247
+ def clear(self) -> None:
248
+ """Clear the evaluation output."""
249
+ self.output = ""
250
+
251
+ def get_display(self) -> Panel:
252
+ """Create a panel displaying the evaluation output with truncation if needed."""
253
+ display_text = truncate_text(text=self.output, max_lines=self.max_lines)
254
+
255
+ title = "[bold]📋 Evaluation Output"
256
+ if len(self.output) == len(display_text):
257
+ title += " (truncated)[/]"
258
+
259
+ return Panel(display_text, title=title, border_style="red", expand=True, padding=(0, 1))
260
+
261
+
262
+ class SolutionPanels:
263
+ """Displays the current and best solutions side by side."""
264
+
265
+ def __init__(self):
266
+ # Current solution
267
+ self.current_node = None
268
+ # Best solution
269
+ self.best_node = None
270
+ self.max_lines = 30 # Approximate number of lines that can fit in each panel
271
+
272
+ def update(self, current_node: Union[Node, None], best_node: Union[Node, None]):
273
+ """Update the current and best solutions."""
274
+ # Update current solution
275
+ self.current_node = current_node
276
+ # Update best solution
277
+ self.best_node = best_node
278
+
279
+ def get_display(self, current_step: int) -> Tuple[Panel, Panel]:
280
+ """Return the current and best solutions as panels."""
281
+ current_code = self.current_node.code if self.current_node is not None else ""
282
+ best_code = self.best_node.code if self.best_node is not None else ""
283
+ best_score = self.best_node.metric if self.best_node is not None else None
284
+
285
+ # Determine if code is too long (approximate)
286
+ current_lines = current_code.count("\n") + 1
287
+ best_lines = best_code.count("\n") + 1
288
+
289
+ # Current solution (without score)
290
+ current_title = f"[bold]💡 Current Solution (Step {current_step})"
291
+ current_panel = Panel(
292
+ Syntax(
293
+ str(current_code),
294
+ "python",
295
+ theme="monokai",
296
+ line_numbers=True,
297
+ word_wrap=False,
298
+ line_range=(0, self.max_lines), # Only show first max_lines lines
299
+ ),
300
+ title=current_title,
301
+ border_style="yellow",
302
+ expand=True,
303
+ padding=(0, 1),
304
+ subtitle="[yellow]↓ truncated ↓[/]" if current_lines > self.max_lines else None,
305
+ )
306
+
307
+ # Best solution
308
+ best_title = f"[bold]🏆 Best Solution ([green]Score: {f'{best_score:.4f}' if best_score is not None else 'N/A'}[/])"
309
+ best_panel = Panel(
310
+ Syntax(
311
+ str(best_code),
312
+ "python",
313
+ theme="monokai",
314
+ line_numbers=True,
315
+ word_wrap=False,
316
+ line_range=(0, self.max_lines), # Only show first max_lines lines
317
+ ),
318
+ title=best_title,
319
+ border_style="green",
320
+ expand=True,
321
+ padding=(0, 1),
322
+ subtitle="[yellow]↓ truncated ↓[/]" if best_lines > self.max_lines else None,
323
+ )
324
+
325
+ return current_panel, best_panel
326
+
327
+
328
+ def create_optimization_layout() -> Layout:
329
+ """Create the main layout for the CLI."""
330
+ layout = Layout()
331
+
332
+ # First split into top, middle, and bottom sections
333
+ layout.split_column(
334
+ Layout(name="top_section", ratio=3), Layout(name="middle_section", ratio=4), Layout(name="eval_output", ratio=2)
335
+ )
336
+
337
+ # Split the top section into left and right
338
+ layout["top_section"].split_row(Layout(name="left_panels", ratio=1), Layout(name="tree", ratio=1))
339
+
340
+ # Split the left panels into summary and thinking
341
+ layout["left_panels"].split_column(Layout(name="summary", ratio=2), Layout(name="plan", ratio=1))
342
+
343
+ # Split the middle section into left and right
344
+ layout["middle_section"].split_row(Layout(name="current_solution", ratio=1), Layout(name="best_solution", ratio=1))
345
+
346
+ return layout
347
+
348
+
349
+ def create_end_optimization_layout() -> Layout:
350
+ """Create the final layout after optimization is complete."""
351
+ layout = Layout()
352
+
353
+ # Create a top section for summary
354
+ layout.split_column(Layout(name="summary", ratio=1), Layout(name="bottom_section", ratio=3))
355
+
356
+ # Split the bottom section into left (best solution) and right ( tree)
357
+ layout["bottom_section"].split_row(Layout(name="best_solution", ratio=1), Layout(name="tree", ratio=1))
358
+
359
+ return layout
weco/utils.py CHANGED
@@ -1,180 +1,119 @@
1
- import base64
1
+ from typing import Any, Dict, List, Tuple, Union
2
+ import json
2
3
  import os
3
- import random
4
- import re
5
- import string
6
- from io import BytesIO
7
- from typing import Dict, Optional, Tuple
8
- from urllib.parse import urlparse
9
-
10
- import requests
11
- from PIL import Image
12
-
13
-
14
- def is_local_image(maybe_local_image: str) -> bool:
15
- """
16
- Check if the file is a local image.
17
-
18
- Parameters
19
- ----------
20
- maybe_local_image : str
21
- The file path.
22
-
23
- Returns
24
- -------
25
- bool
26
- True if the file is a local image, False otherwise.
27
- """
28
- if not os.path.exists(maybe_local_image): # Check if the file exists
29
- return False
30
-
31
- try: # Check if the file is an image
32
- Image.open(maybe_local_image)
33
- except IOError:
34
- return False
35
-
36
- return True
37
-
38
-
39
- def is_base64_image(maybe_base64: str) -> Tuple[bool, Optional[Dict[str, str]]]:
40
- """
41
- Check if the image is a base64 encoded image and if so, extract the image information from the encoded data or URL provided.
42
-
43
- Parameters
44
- ----------
45
- data : str
46
- The image data or URL.
47
-
48
- Returns
49
- -------
50
- Tuple[bool, Optional[Dict[str, str]]]
51
- """
52
- pattern = r"data:(?P<media_type>[\w/]+);(?P<source_type>\w+),(?P<encoding>.*)"
53
- match = re.match(pattern, maybe_base64)
54
- if match:
55
- return True, match.groupdict()
56
-
57
- return False, None
58
-
59
-
60
- def is_public_url_image(maybe_url_image: str) -> bool:
61
- """
62
- Check if the string is a publicly accessible URL
63
-
64
- Parameters
65
- ----------
66
- maybe_url_image : str
67
- The URL to check.
68
-
69
- Returns
70
- -------
71
- bool
72
- True if the URL is publicly accessible, False otherwise.
73
- """
74
- try:
75
- # Check if it is a valid URL
76
- if not urlparse(maybe_url_image).scheme:
77
- return False
78
-
79
- # Check if the URL is publicly accessible
80
- response = requests.head(maybe_url_image)
81
- if response.status_code != 200:
82
- return False
83
-
84
- # Check if the URL is an image
85
- content_type = response.headers.get("content-type")
86
- if not content_type:
87
- return False
88
- if not content_type.startswith("image"):
89
- return False
90
- except Exception:
91
- return False
92
-
93
- return True
94
-
95
-
96
- def get_image_size(image: str, source: str) -> float:
97
- """
98
- Get the size of the image in MB.
99
-
100
- Parameters
101
- ----------
102
- image : str
103
- The image data or URL.
104
-
105
- source : str
106
- The source of the image. It can be 'base64', 'url', or 'local'.
107
-
108
- Returns
109
- -------
110
- float
111
- The size of the image in MB.
112
-
113
- Raises
114
- ------
115
- ValueError
116
- If the image is not a valid image.
117
- """
118
- if source == "base64":
119
- _, base64_info = is_base64_image(maybe_base64=image)
120
- img_data = base64.b64decode(base64_info["encoding"])
121
- elif source == "url":
122
- response = requests.get(image)
123
- response.raise_for_status()
124
- img_data = response.content
125
- elif source == "local":
126
- with open(image, "rb") as f:
127
- img_data = f.read()
4
+ import time
5
+ import subprocess
6
+ from rich.layout import Layout
7
+ from rich.live import Live
8
+ from rich.panel import Panel
9
+ import pathlib
10
+
11
+
12
+ # Env/arg helper functions
13
+ def read_api_keys_from_env() -> Dict[str, Any]:
14
+ """Read API keys from environment variables."""
15
+ keys = {}
16
+ openai_api_key = os.getenv("OPENAI_API_KEY")
17
+ anthropic_api_key = os.getenv("ANTHROPIC_API_KEY")
18
+ if openai_api_key is not None and len(openai_api_key) > 0:
19
+ keys["OPENAI_API_KEY"] = openai_api_key
20
+ if anthropic_api_key is not None and len(anthropic_api_key) > 0:
21
+ keys["ANTHROPIC_API_KEY"] = anthropic_api_key
22
+ return keys
23
+
24
+
25
+ def read_additional_instructions(additional_instructions: str | None) -> str | None:
26
+ """Read additional instructions from a file path string or return the string itself."""
27
+ if additional_instructions is None:
28
+ return None
29
+
30
+ # Try interpreting as a path first
31
+ potential_path = pathlib.Path(additional_instructions)
32
+ if potential_path.exists() and potential_path.is_file():
33
+ return read_from_path(potential_path, is_json=False) # type: ignore # read_from_path returns str when is_json=False
128
34
  else:
129
- raise ValueError("Invalid image input")
130
-
131
- img = Image.open(BytesIO(img_data))
132
- img_byte_arr = BytesIO()
133
- img.save(img_byte_arr, format=img.format)
134
- return img_byte_arr.tell() / 1000000 # MB
135
-
136
-
137
- def preprocess_image(image: Image, file_type: str) -> Tuple:
138
- """
139
- Preprocess the image by converting it to RGB if it has an alpha channel.
140
-
141
- Parameters
142
- ----------
143
- image : Image
144
- The image to preprocess.
145
- file_type : str
146
- The file type of the image.
147
-
148
- Returns
149
- -------
150
- Image
151
- The preprocessed image.
152
- file_type : str
153
- The file type of the image.
154
- """
155
- # Do not rescale or resize. Only do this if latency becomes an issue.
156
- # Remove the alpha channel for PNG and WEBP images if it exists.
157
- if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
158
- image = image.convert("RGB")
159
-
160
- # If the image file type is JPG, convert to JPEG for PIL compatibility.
161
- if file_type == "jpg":
162
- file_type = "jpeg"
163
- return image, file_type
164
-
165
-
166
- def generate_random_base16_code(length: int = 5):
35
+ # If it's not a valid file path, return the string itself
36
+ return additional_instructions
37
+
38
+
39
+ # File helper functions
40
+ def read_from_path(fp: pathlib.Path, is_json: bool = False) -> Union[str, Dict[str, Any]]:
41
+ """Read content from a file path, optionally parsing as JSON."""
42
+ with fp.open("r", encoding="utf-8") as f:
43
+ if is_json:
44
+ return json.load(f)
45
+ return f.read()
46
+
47
+
48
+ def write_to_path(fp: pathlib.Path, content: Union[str, Dict[str, Any]], is_json: bool = False) -> None:
49
+ """Write content to a file path, optionally as JSON."""
50
+ with fp.open("w", encoding="utf-8") as f:
51
+ if is_json:
52
+ json.dump(content, f, indent=4)
53
+ elif isinstance(content, str):
54
+ f.write(content)
55
+ else:
56
+ raise TypeError("Content must be str or Dict[str, Any]")
57
+
58
+
59
+ # Visualization helper functions
60
+ def format_number(n: Union[int, float]) -> str:
61
+ """Format large numbers with K, M, B, T suffixes for better readability."""
62
+ if n >= 1e12:
63
+ return f"{n / 1e12:.1f}T"
64
+ elif n >= 1e9:
65
+ return f"{n / 1e9:.1f}B"
66
+ elif n >= 1e6:
67
+ return f"{n / 1e6:.1f}M"
68
+ elif n >= 1e3:
69
+ return f"{n / 1e3:.1f}K"
70
+ # Handle potential floats that don't need suffix but might need formatting
71
+ if isinstance(n, float):
72
+ # Format floats nicely, avoid excessive precision unless needed
73
+ return f"{n:.4g}" # Use general format, up to 4 significant digits
74
+ return str(n)
75
+
76
+
77
+ def smooth_update(
78
+ live: Live, layout: Layout, sections_to_update: List[Tuple[str, Panel]], transition_delay: float = 0.05
79
+ ) -> None:
167
80
  """
168
- Generate a random base16 code.
81
+ Update sections of the layout with a small delay between each update for a smoother transition effect.
169
82
 
170
- Parameters
171
- ----------
172
- length : int
173
- The length of the code.
174
-
175
- Returns
176
- -------
177
- str
178
- The random base16 code.
83
+ Args:
84
+ live: The Live display instance
85
+ layout: The Layout to update
86
+ sections_to_update: List of (section_name, content) tuples to update
87
+ transition_delay: Delay in seconds between updates (default: 0.05)
179
88
  """
180
- return "".join(random.choices(string.hexdigits, k=length))
89
+ for section, content in sections_to_update:
90
+ layout[section].update(content)
91
+ live.refresh()
92
+ time.sleep(transition_delay)
93
+
94
+
95
+ # Other helper functions
96
+ def run_evaluation(eval_command: str) -> str:
97
+ """Run the evaluation command on the code and return the output."""
98
+
99
+ # Run the eval command as is
100
+ result = subprocess.run(eval_command, shell=True, capture_output=True, text=True, check=False)
101
+
102
+ # Combine stdout and stderr for complete output
103
+ output = result.stderr if result.stderr else ""
104
+ if result.stdout:
105
+ if len(output) > 0:
106
+ output += "\n"
107
+ output += result.stdout
108
+ return output
109
+
110
+
111
+ def truncate_text(text: str, max_lines: int) -> str:
112
+ """Truncate text to a maximum number of lines."""
113
+ lines = text.split("\n")
114
+ if len(lines) > max_lines:
115
+ display_lines = lines[:max_lines]
116
+ display_text = "\n".join(display_lines)
117
+ else:
118
+ display_text = text
119
+ return display_text