kabalan commited on
Commit
1b5c9b4
·
1 Parent(s): eea5114

Switch optimizer and generator from Gemini to Claude

Browse files

Migrates optimizer from Gemini 3.1 Pro to Claude Opus 4.6 and generator from Gemini Flash to Claude Sonnet 4.6. Updates client.py and slide_generator.py to use anthropic SDK instead of google-genai for their respective LLM calls. Adds anthropic>=0.40.0 to pyproject.toml dependencies. Updates log messages to reflect new model names.

Files changed (3) hide show
  1. openenv/client.py +18 -21
  2. openenv/slide_generator.py +16 -27
  3. pyproject.toml +2 -1
openenv/client.py CHANGED
@@ -26,18 +26,17 @@ import textwrap
26
  from pathlib import Path
27
  from typing import Any
28
 
 
 
29
  from dotenv import load_dotenv
30
- from google import genai
31
 
32
  load_dotenv(Path(__file__).parent.parent / ".env")
33
- from google.genai import types
34
- import httpx
35
- from loguru import logger
36
 
37
  from models import SlideSkillObservation
38
 
39
  SERVER_URL = "http://localhost:8000"
40
- OPTIMIZER_MODEL = "gemini-3.1-pro-preview"
41
 
42
  BASELINE_EXAMPLES_CONTENT = "(Empty — no prior optimization rounds)\n"
43
 
@@ -64,7 +63,7 @@ class SlideSkillClient:
64
 
65
  Args:
66
  session_id: Active session ID.
67
- action: Dict matching EditSectionAction or ReplaceFileAction schema.
68
  Must include "action_type" key.
69
  """
70
  payload = {"session_id": session_id, "action": action}
@@ -94,15 +93,13 @@ class SlideSkillClient:
94
 
95
  def call_optimizer_llm(
96
  obs: SlideSkillObservation,
97
- gemini_client: genai.Client,
98
  ) -> dict[str, Any]:
99
  """
100
- Call the optimizer LLM to generate a new DESIGN_RULES.md based on
101
  the evaluation feedback.
102
 
103
  Returns a dict suitable for the step() action parameter.
104
- Uses ReplaceFileAction since the historical optimizer rewrites
105
- the file wholesale.
106
  """
107
  prompt = textwrap.dedent(f"""\
108
  You are a McKinsey slide design optimizer. You are improving a
@@ -141,13 +138,13 @@ def call_optimizer_llm(
141
  no code fences.
142
  """)
143
 
144
- response = gemini_client.models.generate_content(
145
  model=OPTIMIZER_MODEL,
146
- contents=prompt,
147
- config=types.GenerateContentConfig(max_output_tokens=4096),
148
  )
149
 
150
- new_content = response.text.strip()
151
 
152
  return {
153
  "action_type": "replace_file",
@@ -163,7 +160,7 @@ def run_optimization_loop(server_url: str = SERVER_URL, max_steps: int = 7) -> N
163
  This mirrors the historical Skill Forge loop but driven through the
164
  OpenEnv HTTP interface.
165
  """
166
- gemini_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
167
 
168
  with SlideSkillClient(base_url=server_url) as client:
169
  logger.info(f"Starting optimization loop (max {max_steps} steps) | server={server_url}")
@@ -171,9 +168,9 @@ def run_optimization_loop(server_url: str = SERVER_URL, max_steps: int = 7) -> N
171
  logger.info(f"Session: {session_id}")
172
 
173
  # Step 0: baseline — generate slide with unmodified skill files.
174
- logger.info("Step 0/baseline | generating slide (Flash)...")
175
  logger.info("Step 0/baseline | running Node.js + LibreOffice → JPG...")
176
- logger.info("Step 0/baseline | evaluating slide (Pro)...")
177
  obs = client.step(
178
  session_id,
179
  {
@@ -189,11 +186,11 @@ def run_optimization_loop(server_url: str = SERVER_URL, max_steps: int = 7) -> N
189
  logger.info("Episode complete (max_steps reached).")
190
  break
191
 
192
- logger.info(f"Step {step_idx}/{max_steps} | optimizing skill files (Pro)...")
193
- action = call_optimizer_llm(obs, gemini_client)
194
- logger.info(f"Step {step_idx}/{max_steps} | generating slide (Flash)...")
195
  logger.info(f"Step {step_idx}/{max_steps} | running Node.js + LibreOffice → JPG...")
196
- logger.info(f"Step {step_idx}/{max_steps} | evaluating slide (Pro)...")
197
  obs = client.step(session_id, action)
198
 
199
  delta_str = f"{obs.reward * 100:+.0f} pts"
 
26
  from pathlib import Path
27
  from typing import Any
28
 
29
+ import anthropic
30
+ import httpx
31
  from dotenv import load_dotenv
32
+ from loguru import logger
33
 
34
  load_dotenv(Path(__file__).parent.parent / ".env")
 
 
 
35
 
36
  from models import SlideSkillObservation
37
 
38
  SERVER_URL = "http://localhost:8000"
39
+ OPTIMIZER_MODEL = "claude-opus-4-6"
40
 
41
  BASELINE_EXAMPLES_CONTENT = "(Empty — no prior optimization rounds)\n"
42
 
 
63
 
64
  Args:
65
  session_id: Active session ID.
66
+ action: Dict matching any SlideSkillAction schema.
67
  Must include "action_type" key.
68
  """
69
  payload = {"session_id": session_id, "action": action}
 
93
 
94
  def call_optimizer_llm(
95
  obs: SlideSkillObservation,
96
+ anthropic_client: anthropic.Anthropic,
97
  ) -> dict[str, Any]:
98
  """
99
+ Call Claude Opus 4.6 to generate a new DESIGN_RULES.md based on
100
  the evaluation feedback.
101
 
102
  Returns a dict suitable for the step() action parameter.
 
 
103
  """
104
  prompt = textwrap.dedent(f"""\
105
  You are a McKinsey slide design optimizer. You are improving a
 
138
  no code fences.
139
  """)
140
 
141
+ response = anthropic_client.messages.create(
142
  model=OPTIMIZER_MODEL,
143
+ max_tokens=4096,
144
+ messages=[{"role": "user", "content": prompt}],
145
  )
146
 
147
+ new_content = response.content[0].text.strip()
148
 
149
  return {
150
  "action_type": "replace_file",
 
160
  This mirrors the historical Skill Forge loop but driven through the
161
  OpenEnv HTTP interface.
162
  """
163
+ anthropic_client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
164
 
165
  with SlideSkillClient(base_url=server_url) as client:
166
  logger.info(f"Starting optimization loop (max {max_steps} steps) | server={server_url}")
 
168
  logger.info(f"Session: {session_id}")
169
 
170
  # Step 0: baseline — generate slide with unmodified skill files.
171
+ logger.info("Step 0/baseline | generating slide (Sonnet 4.6)...")
172
  logger.info("Step 0/baseline | running Node.js + LibreOffice → JPG...")
173
+ logger.info("Step 0/baseline | evaluating slide (Gemini 3.1 Pro)...")
174
  obs = client.step(
175
  session_id,
176
  {
 
186
  logger.info("Episode complete (max_steps reached).")
187
  break
188
 
189
+ logger.info(f"Step {step_idx}/{max_steps} | optimizing skill files (Opus 4.6)...")
190
+ action = call_optimizer_llm(obs, anthropic_client)
191
+ logger.info(f"Step {step_idx}/{max_steps} | generating slide (Sonnet 4.6)...")
192
  logger.info(f"Step {step_idx}/{max_steps} | running Node.js + LibreOffice → JPG...")
193
+ logger.info(f"Step {step_idx}/{max_steps} | evaluating slide (Gemini 3.1 Pro)...")
194
  obs = client.step(session_id, action)
195
 
196
  delta_str = f"{obs.reward * 100:+.0f} pts"
openenv/slide_generator.py CHANGED
@@ -2,7 +2,7 @@
2
  Slide Generator — orchestrates the full PPT generation pipeline.
3
 
4
  Pipeline (in order):
5
- 1. LLM reads skill files + TASK_PROMPT.md + js_templates + constraints
6
  → writes pptxgenjs JavaScript to generate.js in the session output dir.
7
  2. Code patches are applied to the generated JS.
8
  3. `node generate.js` runs in the session output dir → produces slide.pptx.
@@ -29,8 +29,7 @@ import subprocess
29
  import textwrap
30
  from pathlib import Path
31
 
32
- from google import genai
33
- from google.genai import types
34
 
35
  from models import SlideSkillState
36
 
@@ -50,8 +49,8 @@ PDFTOPPM = (
50
  or "pdftoppm"
51
  )
52
 
53
- # Gemini Flash: fast and cost-effective for code generation.
54
- GENERATOR_MODEL = "gemini-3-flash-preview"
55
  GENERATOR_MAX_TOKENS = 4096
56
 
57
 
@@ -67,7 +66,7 @@ class SlideGenerator:
67
  self.task_prompt = task_prompt_path.read_text(encoding="utf-8")
68
  self.pptx_skill_dir = pptx_skill_dir
69
  self.reference_dir = reference_dir
70
- self._client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
71
 
72
  def generate(
73
  self,
@@ -180,7 +179,7 @@ class SlideGenerator:
180
  node_error: str | None = None,
181
  ) -> str:
182
  """
183
- Call the generator LLM with skill files + task prompt as context.
184
 
185
  Reads skill files from the session directory first (so agent edits
186
  take effect), falling back to the repo's pptx/ dir for any files
@@ -191,8 +190,6 @@ class SlideGenerator:
191
  design_rules = (session_dir / "DESIGN_RULES.md").read_text(encoding="utf-8")
192
  examples = (session_dir / "EXAMPLES.md").read_text(encoding="utf-8")
193
 
194
- # Load pptx tooling from session dir (edited copies) with fallback
195
- # to repo originals.
196
  pptx_skill = self._read_pptx_skill(session_dir)
197
 
198
  system_prompt = textwrap.dedent("""\
@@ -221,7 +218,6 @@ class SlideGenerator:
221
  {self.task_prompt}
222
  """)
223
 
224
- # Inject hard constraints if any.
225
  if state and state.constraints:
226
  constraints_text = "\n".join(f"- {c}" for c in state.constraints)
227
  user_message += textwrap.dedent(f"""
@@ -230,7 +226,6 @@ class SlideGenerator:
230
  {constraints_text}
231
  """)
232
 
233
- # Inject JS templates if any.
234
  if state and state.js_templates:
235
  templates_text = ""
236
  for name, code in state.js_templates.items():
@@ -254,27 +249,22 @@ class SlideGenerator:
254
  {node_error}
255
  """)
256
 
257
- response = self._client.models.generate_content(
258
  model=GENERATOR_MODEL,
259
- contents=user_message,
260
- config=types.GenerateContentConfig(
261
- system_instruction=system_prompt,
262
- max_output_tokens=GENERATOR_MAX_TOKENS,
263
- ),
264
  )
265
 
266
- code = response.text.strip()
267
 
268
- # Extract from markdown code fence if present (LLMs often add them
269
- # despite instructions). Handles ```javascript, ```js, or plain ```.
270
  fence_match = re.search(r"```(?:javascript|js)?\n(.*?)```", code, re.DOTALL)
271
  if fence_match:
272
  code = fence_match.group(1).strip()
273
 
274
  # Rewrite all bare require('pkg') calls to absolute paths so the
275
- # script works when run from any /tmp/ directory. We only rewrite
276
- # packages that actually exist in node_modules; unknown packages are
277
- # left untouched (they'd fail at runtime but at least not silently).
278
  node_modules = REPO_ROOT / "node_modules"
279
 
280
  def _rewrite_require(m: re.Match) -> str:
@@ -283,12 +273,11 @@ class SlideGenerator:
283
  pkg_path = node_modules / pkg
284
  if pkg_path.exists():
285
  return f"require({quote}{pkg_path}{quote})"
286
- return m.group(0) # leave unknown packages as-is
287
 
288
  code = re.sub(r"require\((['\"])([^./][^'\"]*)\1\)", _rewrite_require, code)
289
 
290
- # LLMs sometimes emit the require line twice. Keep only the first
291
- # declaration to avoid "Identifier already declared" SyntaxError.
292
  seen: set[str] = set()
293
  deduped = []
294
  for line in code.splitlines():
@@ -335,7 +324,7 @@ class SlideGenerator:
335
  cwd=cwd,
336
  capture_output=True,
337
  text=True,
338
- timeout=300, # 5 min hard limit per stage
339
  )
340
  if result.returncode != 0:
341
  raise RuntimeError(
 
2
  Slide Generator — orchestrates the full PPT generation pipeline.
3
 
4
  Pipeline (in order):
5
+ 1. Claude Sonnet 4.6 reads skill files + TASK_PROMPT.md + js_templates + constraints
6
  → writes pptxgenjs JavaScript to generate.js in the session output dir.
7
  2. Code patches are applied to the generated JS.
8
  3. `node generate.js` runs in the session output dir → produces slide.pptx.
 
29
  import textwrap
30
  from pathlib import Path
31
 
32
+ import anthropic
 
33
 
34
  from models import SlideSkillState
35
 
 
49
  or "pdftoppm"
50
  )
51
 
52
+ # Claude Sonnet 4.6: fast and cost-effective for code generation.
53
+ GENERATOR_MODEL = "claude-sonnet-4-6"
54
  GENERATOR_MAX_TOKENS = 4096
55
 
56
 
 
66
  self.task_prompt = task_prompt_path.read_text(encoding="utf-8")
67
  self.pptx_skill_dir = pptx_skill_dir
68
  self.reference_dir = reference_dir
69
+ self._client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
70
 
71
  def generate(
72
  self,
 
179
  node_error: str | None = None,
180
  ) -> str:
181
  """
182
+ Call Claude Sonnet 4.6 with skill files + task prompt as context.
183
 
184
  Reads skill files from the session directory first (so agent edits
185
  take effect), falling back to the repo's pptx/ dir for any files
 
190
  design_rules = (session_dir / "DESIGN_RULES.md").read_text(encoding="utf-8")
191
  examples = (session_dir / "EXAMPLES.md").read_text(encoding="utf-8")
192
 
 
 
193
  pptx_skill = self._read_pptx_skill(session_dir)
194
 
195
  system_prompt = textwrap.dedent("""\
 
218
  {self.task_prompt}
219
  """)
220
 
 
221
  if state and state.constraints:
222
  constraints_text = "\n".join(f"- {c}" for c in state.constraints)
223
  user_message += textwrap.dedent(f"""
 
226
  {constraints_text}
227
  """)
228
 
 
229
  if state and state.js_templates:
230
  templates_text = ""
231
  for name, code in state.js_templates.items():
 
249
  {node_error}
250
  """)
251
 
252
+ response = self._client.messages.create(
253
  model=GENERATOR_MODEL,
254
+ max_tokens=GENERATOR_MAX_TOKENS,
255
+ system=system_prompt,
256
+ messages=[{"role": "user", "content": user_message}],
 
 
257
  )
258
 
259
+ code = response.content[0].text.strip()
260
 
261
+ # Extract from markdown code fence if present.
 
262
  fence_match = re.search(r"```(?:javascript|js)?\n(.*?)```", code, re.DOTALL)
263
  if fence_match:
264
  code = fence_match.group(1).strip()
265
 
266
  # Rewrite all bare require('pkg') calls to absolute paths so the
267
+ # script works when run from any tmp directory.
 
 
268
  node_modules = REPO_ROOT / "node_modules"
269
 
270
  def _rewrite_require(m: re.Match) -> str:
 
273
  pkg_path = node_modules / pkg
274
  if pkg_path.exists():
275
  return f"require({quote}{pkg_path}{quote})"
276
+ return m.group(0)
277
 
278
  code = re.sub(r"require\((['\"])([^./][^'\"]*)\1\)", _rewrite_require, code)
279
 
280
+ # Deduplicate require lines to avoid "Identifier already declared".
 
281
  seen: set[str] = set()
282
  deduped = []
283
  for line in code.splitlines():
 
324
  cwd=cwd,
325
  capture_output=True,
326
  text=True,
327
+ timeout=300,
328
  )
329
  if result.returncode != 0:
330
  raise RuntimeError(
pyproject.toml CHANGED
@@ -10,7 +10,8 @@ requires-python = ">=3.12"
10
 
11
  # Core runtime dependencies (required for the environment to run)
12
  dependencies = [
13
- "google-genai>=1.0.0", # Gemini API client (generator + evaluator + optimizer)
 
14
  "pydantic>=2.6.0", # Data models with discriminated unions
15
  "httpx>=0.27.0", # HTTP client for client.py
16
  "loguru>=0.7.0", # Structured logging for client
 
10
 
11
  # Core runtime dependencies (required for the environment to run)
12
  dependencies = [
13
+ "anthropic>=0.40.0", # Claude API client (generator: Sonnet 4.6, optimizer: Opus 4.6)
14
+ "google-genai>=1.0.0", # Gemini API client (evaluator: Gemini 3.1 Pro)
15
  "pydantic>=2.6.0", # Data models with discriminated unions
16
  "httpx>=0.27.0", # HTTP client for client.py
17
  "loguru>=0.7.0", # Structured logging for client