vbagent 0.1.1__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.
@@ -2,20 +2,239 @@
2
2
 
3
3
  Checks TikZ/PGF code for syntax errors, best practices,
4
4
  and physics diagram conventions.
5
+
6
+ Supports two modes:
7
+ 1. Legacy mode: Returns full corrected content (check_tikz)
8
+ 2. Patch mode: Uses apply_patch tool for structured diffs (check_tikz_with_patch)
5
9
  """
6
10
 
7
11
  import re
12
+ from dataclasses import dataclass
13
+ from typing import Optional
8
14
 
9
15
  from vbagent.agents.base import create_agent, run_agent_sync
10
- from vbagent.prompts.tikz_checker import SYSTEM_PROMPT, USER_TEMPLATE
16
+ from vbagent.prompts.tikz_checker import (
17
+ SYSTEM_PROMPT,
18
+ USER_TEMPLATE,
19
+ PATCH_SYSTEM_PROMPT,
20
+ PATCH_USER_TEMPLATE,
21
+ )
11
22
 
12
23
 
13
- # Create the TikZ checker agent
14
- tikz_checker_agent = create_agent(
15
- name="TikZChecker",
16
- instructions=SYSTEM_PROMPT,
17
- agent_type="tikz_checker", # Uses tikz_checker model config
18
- )
24
+ @dataclass
25
+ class PatchResult:
26
+ """Result from patch-based TikZ check."""
27
+ passed: bool
28
+ summary: str
29
+ corrected_content: str # Empty if passed
30
+ patches_applied: int
31
+ patch_errors: list[str]
32
+
33
+
34
+ def _get_tikz_reference_context(
35
+ classification=None,
36
+ diagram_type: Optional[str] = None,
37
+ ) -> str:
38
+ """Get TikZ reference context for the checker.
39
+
40
+ Uses the same TikZReferenceStore as the generator for consistency.
41
+
42
+ Args:
43
+ classification: Optional ClassificationResult for metadata matching
44
+ diagram_type: Optional filter by diagram type (e.g., 'circuit')
45
+
46
+ Returns:
47
+ Formatted context string with matching TikZ examples
48
+ """
49
+ try:
50
+ from vbagent.references.tikz_store import TikZReferenceStore
51
+
52
+ store = TikZReferenceStore.get_instance()
53
+
54
+ if not store.enabled or not store.references:
55
+ return ""
56
+
57
+ # If classification provided, use metadata matching
58
+ if classification:
59
+ context = store.get_context_for_classification(classification)
60
+ elif diagram_type:
61
+ # Filter by diagram type
62
+ refs = store.list_references(diagram_type=diagram_type)
63
+ refs = refs[:store.max_examples]
64
+ if not refs:
65
+ return ""
66
+
67
+ parts = []
68
+ for ref in refs:
69
+ header = f"% === Reference: {ref.name} ==="
70
+ if ref.metadata.diagram_type:
71
+ header += f"\n% Type: {ref.metadata.diagram_type}"
72
+ if ref.metadata.topic:
73
+ header += f", Topic: {ref.metadata.topic}"
74
+ parts.append(f"{header}\n{ref.tikz_code}")
75
+ context = "\n\n".join(parts)
76
+ else:
77
+ # Get general examples (top by any criteria)
78
+ refs = store.references[:store.max_examples]
79
+ if not refs:
80
+ return ""
81
+
82
+ parts = []
83
+ for ref in refs:
84
+ header = f"% === Reference: {ref.name} ==="
85
+ if ref.metadata.diagram_type:
86
+ header += f"\n% Type: {ref.metadata.diagram_type}"
87
+ parts.append(f"{header}\n{ref.tikz_code}")
88
+ context = "\n\n".join(parts)
89
+
90
+ if not context:
91
+ return ""
92
+
93
+ return f"""
94
+ ## TikZ Reference Examples
95
+
96
+ Use these as style references for corrections:
97
+
98
+ {context}
99
+
100
+ ---
101
+ """
102
+ except Exception:
103
+ return ""
104
+
105
+
106
+ def create_tikz_checker_agent(
107
+ use_context: bool = True,
108
+ classification=None,
109
+ diagram_type: Optional[str] = None,
110
+ ):
111
+ """Create a TikZ checker agent with optional reference context.
112
+
113
+ Args:
114
+ use_context: Whether to include reference context
115
+ classification: Optional ClassificationResult for metadata matching
116
+ diagram_type: Optional filter by diagram type (e.g., 'circuit')
117
+
118
+ Returns:
119
+ Configured Agent instance
120
+ """
121
+ prompt = SYSTEM_PROMPT
122
+
123
+ if use_context:
124
+ context = _get_tikz_reference_context(classification, diagram_type)
125
+ if context:
126
+ prompt = prompt + "\n" + context
127
+
128
+ return create_agent(
129
+ name="TikZChecker",
130
+ instructions=prompt,
131
+ agent_type="tikz_checker",
132
+ )
133
+
134
+
135
+ class TikZPatchEditor:
136
+ """Editor for collecting TikZ patches without immediately applying them.
137
+
138
+ This editor collects patch operations so they can be reviewed
139
+ before being applied to the file system.
140
+ """
141
+
142
+ def __init__(self, file_path: str, original_content: str):
143
+ """Initialize the editor.
144
+
145
+ Args:
146
+ file_path: Path to the file being edited
147
+ original_content: Original content of the file
148
+ """
149
+ self.file_path = file_path
150
+ self.original_content = original_content
151
+ self.current_content = original_content
152
+ self.patches: list[dict] = []
153
+ self.errors: list[str] = []
154
+
155
+ def create_file(self, operation) -> dict:
156
+ """Handle create_file operation (not expected for TikZ checker)."""
157
+ self.errors.append(f"Unexpected create_file for {operation.path}")
158
+ return {"status": "failed", "output": "create_file not supported"}
159
+
160
+ def update_file(self, operation) -> dict:
161
+ """Handle update_file operation by applying the diff."""
162
+ from agents import apply_diff
163
+
164
+ try:
165
+ # Apply the V4A diff
166
+ new_content = apply_diff(self.current_content, operation.diff)
167
+ self.current_content = new_content
168
+ self.patches.append({
169
+ "type": "update_file",
170
+ "path": operation.path,
171
+ "diff": operation.diff,
172
+ })
173
+ return {"status": "completed", "output": f"Updated {operation.path}"}
174
+ except Exception as e:
175
+ error_msg = f"Failed to apply patch: {e}"
176
+ self.errors.append(error_msg)
177
+ return {"status": "failed", "output": error_msg}
178
+
179
+ def delete_file(self, operation) -> dict:
180
+ """Handle delete_file operation (not expected for TikZ checker)."""
181
+ self.errors.append(f"Unexpected delete_file for {operation.path}")
182
+ return {"status": "failed", "output": "delete_file not supported"}
183
+
184
+
185
+ def create_tikz_patch_agent(
186
+ use_context: bool = True,
187
+ classification=None,
188
+ editor: Optional[TikZPatchEditor] = None,
189
+ diagram_type: Optional[str] = None,
190
+ ):
191
+ """Create a TikZ checker agent with apply_patch tool.
192
+
193
+ This agent uses the apply_patch tool to emit structured diffs
194
+ instead of returning full corrected content.
195
+
196
+ Args:
197
+ use_context: Whether to include reference context
198
+ classification: Optional ClassificationResult for metadata matching
199
+ editor: Optional TikZPatchEditor instance (created if not provided)
200
+ diagram_type: Optional filter by diagram type (e.g., 'circuit')
201
+
202
+ Returns:
203
+ Configured Agent instance with apply_patch tool
204
+ """
205
+ from agents import Agent, ApplyPatchTool
206
+ from vbagent.config import get_model, get_model_settings
207
+
208
+ prompt = PATCH_SYSTEM_PROMPT
209
+
210
+ if use_context:
211
+ context = _get_tikz_reference_context(classification, diagram_type)
212
+ if context:
213
+ prompt = prompt + "\n" + context
214
+
215
+ # Create a dummy editor if none provided (will be replaced at runtime)
216
+ if editor is None:
217
+ editor = TikZPatchEditor("dummy.tex", "")
218
+
219
+ return Agent(
220
+ name="TikZPatchChecker",
221
+ instructions=prompt,
222
+ model=get_model("tikz_checker"),
223
+ model_settings=get_model_settings("tikz_checker"),
224
+ tools=[ApplyPatchTool(editor=editor)],
225
+ )
226
+
227
+
228
+ # Legacy agent (created lazily for backward compatibility)
229
+ _tikz_checker_agent = None
230
+
231
+
232
+ def _get_tikz_checker_agent():
233
+ """Get or create the legacy TikZ checker agent."""
234
+ global _tikz_checker_agent
235
+ if _tikz_checker_agent is None:
236
+ _tikz_checker_agent = create_tikz_checker_agent(use_context=False)
237
+ return _tikz_checker_agent
19
238
 
20
239
 
21
240
  def clean_latex_output(latex: str) -> str:
@@ -38,30 +257,25 @@ def clean_latex_output(latex: str) -> str:
38
257
  return latex.strip()
39
258
 
40
259
 
260
+
41
261
  def check_tikz(
42
262
  full_content: str,
43
263
  image_path: str | None = None,
264
+ use_context: bool = True,
265
+ classification=None,
44
266
  ) -> tuple[bool, str, str]:
45
- """Check TikZ code for errors and best practices.
267
+ """Check TikZ code for errors and best practices (legacy mode).
46
268
 
47
- Analyzes the content for:
48
- - TikZ syntax errors
49
- - Missing libraries or packages
50
- - Best practice violations
51
- - Physics diagram convention issues
52
-
53
- If an image is provided, the checker can compare the TikZ output
54
- against the reference image for accuracy.
269
+ Returns full corrected content. For structured diffs, use check_tikz_with_patch().
55
270
 
56
271
  Args:
57
272
  full_content: Full LaTeX file content containing TikZ code
58
273
  image_path: Optional path to reference image for comparison
274
+ use_context: Whether to include reference context
275
+ classification: Optional ClassificationResult for metadata matching
59
276
 
60
277
  Returns:
61
278
  Tuple of (passed, summary, corrected_content)
62
- - passed: True if no errors found
63
- - summary: Description of what was fixed (or "PASSED")
64
- - corrected_content: The corrected file content (empty if passed)
65
279
 
66
280
  Raises:
67
281
  ValueError: If content is empty
@@ -71,6 +285,9 @@ def check_tikz(
71
285
  if not full_content.strip():
72
286
  raise ValueError("Content cannot be empty")
73
287
 
288
+ # Create agent with context
289
+ agent = create_tikz_checker_agent(use_context, classification)
290
+
74
291
  # Use string replace instead of .format() to avoid issues with LaTeX curly braces
75
292
  message_text = USER_TEMPLATE.replace('{full_content}', full_content)
76
293
 
@@ -81,12 +298,110 @@ def check_tikz(
81
298
  else:
82
299
  message = message_text
83
300
 
84
- raw_result = run_agent_sync(tikz_checker_agent, message)
301
+ raw_result = run_agent_sync(agent, message)
85
302
  result = clean_latex_output(raw_result)
86
303
 
87
304
  return parse_check_result(result, "TIKZ_CHECK")
88
305
 
89
306
 
307
+ def check_tikz_with_patch(
308
+ file_path: str,
309
+ full_content: str,
310
+ image_path: str | None = None,
311
+ use_context: bool = True,
312
+ classification=None,
313
+ ref_diagram_type: Optional[str] = None,
314
+ ) -> PatchResult:
315
+ """Check TikZ code using apply_patch tool for structured diffs.
316
+
317
+ Uses OpenAI's apply_patch tool to emit V4A diffs that can be
318
+ reviewed and applied incrementally.
319
+
320
+ Args:
321
+ file_path: Path to the file being checked (for patch operations)
322
+ full_content: Full LaTeX file content containing TikZ code
323
+ image_path: Optional path to reference image for comparison
324
+ use_context: Whether to include reference context
325
+ classification: Optional ClassificationResult for metadata matching
326
+ ref_diagram_type: Filter reference examples by diagram type (e.g., 'circuit')
327
+
328
+ Returns:
329
+ PatchResult with pass/fail status, summary, and corrected content
330
+
331
+ Raises:
332
+ ValueError: If content is empty
333
+ """
334
+ from agents import Runner
335
+ from vbagent.agents.base import create_image_message, _print_agent_info
336
+
337
+ if not full_content.strip():
338
+ raise ValueError("Content cannot be empty")
339
+
340
+ # Create editor to collect patches
341
+ editor = TikZPatchEditor(file_path, full_content)
342
+
343
+ # Create patch agent with the editor
344
+ agent = create_tikz_patch_agent(use_context, classification, editor, ref_diagram_type)
345
+
346
+ # Build the input message
347
+ message_text = PATCH_USER_TEMPLATE.replace('{file_path}', file_path)
348
+ message_text = message_text.replace('{full_content}', full_content)
349
+
350
+ if image_path:
351
+ message_text += "\n\n[Reference image provided - compare TikZ output against this image for accuracy]"
352
+ message = create_image_message(image_path, message_text)
353
+ else:
354
+ message = message_text
355
+
356
+ _print_agent_info(agent)
357
+
358
+ # Run the agent
359
+ result = Runner.run_sync(agent, input=message)
360
+
361
+ # Check if agent returned text indicating pass
362
+ final_output = result.final_output or ""
363
+ if "PASSED" in final_output.upper() or "no errors" in final_output.lower():
364
+ return PatchResult(
365
+ passed=True,
366
+ summary="No TikZ errors found",
367
+ corrected_content="",
368
+ patches_applied=0,
369
+ patch_errors=[],
370
+ )
371
+
372
+ # Get results from editor
373
+ patches_applied = len(editor.patches)
374
+ patch_errors = editor.errors
375
+
376
+ # Determine pass/fail
377
+ if patches_applied == 0 and not patch_errors:
378
+ return PatchResult(
379
+ passed=True,
380
+ summary="No TikZ errors found",
381
+ corrected_content="",
382
+ patches_applied=0,
383
+ patch_errors=[],
384
+ )
385
+
386
+ # Build summary
387
+ if patches_applied > 0:
388
+ summary = f"Applied {patches_applied} patch(es)"
389
+ else:
390
+ summary = "TikZ issues found but patches failed"
391
+
392
+ if patch_errors:
393
+ summary += f" ({len(patch_errors)} error(s))"
394
+
395
+ return PatchResult(
396
+ passed=False,
397
+ summary=summary,
398
+ corrected_content=editor.current_content if patches_applied > 0 else "",
399
+ patches_applied=patches_applied,
400
+ patch_errors=patch_errors,
401
+ )
402
+
403
+
404
+
90
405
  def parse_check_result(result: str, check_type: str) -> tuple[bool, str, str]:
91
406
  """Parse the check result to extract pass/fail status and content.
92
407
 
@@ -100,7 +415,6 @@ def parse_check_result(result: str, check_type: str) -> tuple[bool, str, str]:
100
415
  # Check if passed
101
416
  passed_pattern = rf'%\s*{check_type}:\s*PASSED'
102
417
  if re.search(passed_pattern, result, re.IGNORECASE):
103
- # Extract the summary after PASSED
104
418
  match = re.search(rf'%\s*{check_type}:\s*PASSED\s*[-–—]?\s*(.*?)(?:\n|$)', result, re.IGNORECASE)
105
419
  summary = match.group(1).strip() if match else "No TikZ errors found"
106
420
  return True, summary, ""
@@ -118,26 +432,12 @@ def parse_check_result(result: str, check_type: str) -> tuple[bool, str, str]:
118
432
 
119
433
 
120
434
  def has_tikz_passed(result: str) -> bool:
121
- """Check if TikZ check passed.
122
-
123
- Args:
124
- result: Raw result from checker
125
-
126
- Returns:
127
- True if TikZ check passed
128
- """
435
+ """Check if TikZ check passed."""
129
436
  return '% TIKZ_CHECK: PASSED' in result or 'TIKZ_CHECK: PASSED' in result.upper()
130
437
 
131
438
 
132
439
  def has_tikz_environment(content: str) -> bool:
133
- """Check if content contains TikZ code.
134
-
135
- Args:
136
- content: LaTeX content to check
137
-
138
- Returns:
139
- True if TikZ environment or commands found
140
- """
440
+ """Check if content contains TikZ code."""
141
441
  tikz_patterns = [
142
442
  r'\\begin\{tikzpicture\}',
143
443
  r'\\tikz\s*[{\[]',
@@ -151,3 +451,17 @@ def has_tikz_environment(content: str) -> bool:
151
451
  if re.search(pattern, content):
152
452
  return True
153
453
  return False
454
+
455
+
456
+ # Backward compatibility: expose tikz_checker_agent as module-level
457
+ class _TikzCheckerAgentProxy:
458
+ """Proxy for lazy loading tikz_checker_agent."""
459
+ _agent = None
460
+
461
+ def __getattr__(self, name):
462
+ if self._agent is None:
463
+ self._agent = _get_tikz_checker_agent()
464
+ return getattr(self._agent, name)
465
+
466
+
467
+ tikz_checker_agent = _TikzCheckerAgentProxy()
vbagent/agents/variant.py CHANGED
@@ -183,3 +183,77 @@ def generate_variant(
183
183
 
184
184
  # Clean up markdown artifacts from LLM output
185
185
  return clean_latex_output(raw_result)
186
+
187
+
188
+ # Convenience functions for specific variant types
189
+
190
+ def generate_numerical_variant(
191
+ source_latex: str,
192
+ ideas: Optional[IdeaResult] = None,
193
+ use_context: bool = True,
194
+ ) -> str:
195
+ """Generate a numerical variant (changes only numerical values).
196
+
197
+ Args:
198
+ source_latex: The source problem in LaTeX format
199
+ ideas: Optional IdeaResult with extracted concepts
200
+ use_context: Whether to include reference context in prompt
201
+
202
+ Returns:
203
+ The generated variant in LaTeX format
204
+ """
205
+ return generate_variant(source_latex, "numerical", ideas, use_context)
206
+
207
+
208
+ def generate_context_variant(
209
+ source_latex: str,
210
+ ideas: Optional[IdeaResult] = None,
211
+ use_context: bool = True,
212
+ ) -> str:
213
+ """Generate a context variant (changes only the scenario/context).
214
+
215
+ Args:
216
+ source_latex: The source problem in LaTeX format
217
+ ideas: Optional IdeaResult with extracted concepts
218
+ use_context: Whether to include reference context in prompt
219
+
220
+ Returns:
221
+ The generated variant in LaTeX format
222
+ """
223
+ return generate_variant(source_latex, "context", ideas, use_context)
224
+
225
+
226
+ def generate_conceptual_variant(
227
+ source_latex: str,
228
+ ideas: Optional[IdeaResult] = None,
229
+ use_context: bool = True,
230
+ ) -> str:
231
+ """Generate a conceptual variant (changes the core physics concept).
232
+
233
+ Args:
234
+ source_latex: The source problem in LaTeX format
235
+ ideas: Optional IdeaResult with extracted concepts
236
+ use_context: Whether to include reference context in prompt
237
+
238
+ Returns:
239
+ The generated variant in LaTeX format
240
+ """
241
+ return generate_variant(source_latex, "conceptual", ideas, use_context)
242
+
243
+
244
+ def generate_calculus_variant(
245
+ source_latex: str,
246
+ ideas: Optional[IdeaResult] = None,
247
+ use_context: bool = True,
248
+ ) -> str:
249
+ """Generate a calculus variant (adds calculus-based modifications).
250
+
251
+ Args:
252
+ source_latex: The source problem in LaTeX format
253
+ ideas: Optional IdeaResult with extracted concepts
254
+ use_context: Whether to include reference context in prompt
255
+
256
+ Returns:
257
+ The generated variant in LaTeX format
258
+ """
259
+ return generate_variant(source_latex, "calculus", ideas, use_context)