-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
2650 lines (2352 loc) · 114 KB
/
main.py
File metadata and controls
2650 lines (2352 loc) · 114 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# main3.1.py
import os
import json
import re
import asyncio
import base64
import csv
import hashlib
import sys
import ast
import shutil
import argparse
from datetime import datetime
from dotenv import load_dotenv
from azure.identity.aio import AzureCliCredential
from agent_framework import ChatMessage, TextContent, DataContent, Role
from agent_framework.azure import AzureAIClient
version = "01" # V34 base with bugfixing agent
os.environ["CAD_WORKDIR"] = f"./cad_runs_v{version}"
from run_freecad import run_freecad_script, run_occ_render_images
dotenv_path = 'enviromental.env'
load_dotenv(dotenv_path)
# ----------------------------
# Runtime safety (timeouts/retries/heartbeats)
# ----------------------------
LLM_TIMEOUT_S = int(os.getenv("LLM_TIMEOUT_S", "180"))
LLM_RETRIES = int(os.getenv("LLM_RETRIES", "3"))
LLM_RETRY_BACKOFF_S = float(os.getenv("LLM_RETRY_BACKOFF_S", "2"))
def _ts_local() -> str:
# human readable local timestamp for console logs
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
async def call_agent(agent, prompt, *, label: str, timeout_s: int | None = None, retries: int | None = None):
"""Call an agent with a hard timeout + retries.
Why: In long batch runs, network/API calls can occasionally stall without raising,
which looks like the script "stops". This wrapper makes those failures visible
and recoverable.
"""
timeout_s = int(timeout_s or LLM_TIMEOUT_S)
retries = int(retries or LLM_RETRIES)
last_exc: Exception | None = None
for attempt in range(1, retries + 1):
try:
print(f"[{_ts_local()}] LLM call start: {label} (attempt {attempt}/{retries}, timeout={timeout_s}s)")
sys.stdout.flush()
res = await asyncio.wait_for(agent.run(prompt), timeout=timeout_s)
print(f"[{_ts_local()}] LLM call ok: {label} (attempt {attempt}/{retries})")
sys.stdout.flush()
return res
except asyncio.TimeoutError as e:
last_exc = e
print(f"[{_ts_local()}] LLM call TIMEOUT: {label} (attempt {attempt}/{retries})")
sys.stdout.flush()
except Exception as e:
last_exc = e
print(f"[{_ts_local()}] LLM call ERROR: {label} (attempt {attempt}/{retries}) -> {type(e).__name__}: {e}")
sys.stdout.flush()
# backoff before retry
if attempt < retries:
await asyncio.sleep(LLM_RETRY_BACKOFF_S * attempt)
# Exhausted
raise RuntimeError(f"LLM call failed after {retries} attempts: {label}") from last_exc
# ----------------------------
# Agent instructions
# ----------------------------
ARCHITECT_INSTRUCTIONS = """
You are an experienced designer (Senior CAD Engineer) for FreeCAD.
Respond ONLY with valid JSON (no Markdown, no explanations).
Goal: Produce a robust, parametric design and feature plan.
You do NOT write FreeCAD code. You decide feature order, references (datums), symmetries, patterns, and parameters.
EXACT output format:
{
"run_id": "<only a-z0-9- max 20, start/end alphanumeric>",
"intent": "<short type, e.g., bracket|plate|tube|wheel-rim|housing|gear-like|generic>",
"params": {"key": "value", "...": "..."},
"datums": ["..."],
"feature_plan": [
{
"id": "f1",
"type": "base|cut|pattern|detail",
"op": "pad|revolve|extrude|pocket|hole|boolean_cut|fillet|chamfer|draft",
"sketch": "<name or null>",
"ref": "<datum/face/axis name>",
"dims": {"key": "value"},
"notes": "<brief>"
}
],
"acceptance": {
"expected_bbox": null,
"must_have": ["..."]
},
"plan": ["<short construction plan in steps>"]
}
Rules / designer logic:
- Think in feature tree/order: Base -> main cuts -> patterns -> details.
- Robustness: prefer sketch+pad/pocket/hole/pattern over many booleans.
- If unclear: allow boolean_cut only as a last resort and sparingly.
- Parameterize everything important (dimensions, count, bolt circle, wall thicknesses, radii, symmetry).
- If the user is vague ("car wheel"), choose plausible default parameters and list them in params.
- For circular/bolt patterns, use parameters: n, pcd/bolt_circle_diameter, radius.
- Units: mm.
- IMPORTANT: All values in params/dims/acceptance must be JSON literals (numbers/strings/bool/null). NO expressions like "112 + 30"; compute it first (e.g., 142).
- run_id strict: [a-z0-9-], max 20.
"""
IMPLEMENTER_INSTRUCTIONS = """
You are a FreeCAD implementer (CAD scripter). You receive a feature plan (JSON) from the designer.
Your task: produce FreeCAD Python code that robustly implements this plan.
Respond ONLY with valid JSON (no Markdown, no explanations):
{
"plan": ["<step>", "<step>", "..."],
"run_id": "<only a-z0-9- max 20>",
"script": "<FreeCAD Python Script>"
}
Requirements for "script" (MUST satisfy all):
- Only these imports (exactly these four lines, in exactly this order):
import FreeCAD as App
import Part
import os, json
import math
- Must create a document:
doc = App.newDocument("Model")
- Must keep the result in variable "solid".
- Must create exactly ONE object (name: Result):
obj = doc.addObject("Part::Feature","Result"); obj.Shape = solid; doc.recompute()
- Must export STEP (in cwd):
out_path = os.path.join(os.getcwd(),"result.step")
obj.Shape.exportStep(out_path)
- Must print EXACTLY ONE JSON line at the end, and it MUST look like this:
print(json.dumps({"checks": {"bbox": [bb.XMin,bb.YMin,bb.ZMin,bb.XMax,bb.YMax,bb.ZMax], "volume": float(obj.Shape.Volume)}}))
(bb is obj.Shape.BoundBox)
- Do NOT use the variable name `step_path` in the FreeCAD script. The export path is strictly `out_path` (os.getcwd()/result.step) and is set only in the footer.
- Avoid `Base.*` types (e.g., Base.Placement/Base.Matrix). Use only Part shapes + Shape.rotate(...) + App.Vector(...).
Rules:
API CHEATSHEET (FreeCAD 1.0.x, allowed in this runner):
You may use ONLY Part + App.Vector + Shape.rotate. Use NO workbenches (no Sketcher/PartDesign/Draft/Import).
Allowed primitives (positional args only, no keyword args!):
- Part.makeBox(l, w, h)
- Part.makeCylinder(radius, height)
- Part.makeCone(r1, r2, height)
- Part.makeSphere(radius)
- Part.makeTorus(r1, r2)
Allowed boolean/shape operations (on shapes):
- solid = solid.fuse(other) # union
- solid = solid.cut(other) # subtract
- solid = solid.common(other) # intersection (rare)
- solid = solid.copy() # if needed
Positioning/rotation (without Base.*):
- other.translate(App.Vector(x,y,z))
- other.rotate(App.Vector(0,0,0), App.Vector(ax,ay,az), deg)
Sketch/hole/pattern logic (how to translate the feature plan):
- pad/extrude -> Part.makeBox(...) or Part.makeCylinder(...)
- hole/pocket/boolean_cut -> Part.makeCylinder(...); solid = solid.cut(hole)
- pattern (circular) -> loop over angles: create hole, translate, cut
- fillet/chamfer -> OMIT (only if explicitly needed and stable)
IMPORTANT: Variable discipline (prevents 80% of your bugs):
- ALWAYS keep exactly one running result variable: `solid`.
- At the start of the script, define ONE param dict `P = {...}` (numbers/strings/bool/null only).
- Afterwards, use dimensions only as `P["..."]` or as literals.
- Avoid invented variable names like `ear_hole_offset_z`, `channel_y_positions`, etc.
- ALWAYS create helper geometry as a local variable (e.g., `tool`) right before use.
- No access to variables before they are assigned (no free variables!).
- No additional imports (beyond the 4 above).
- No file paths outside os.getcwd().
- Units: mm.
- If angles/trigonometry are needed: use only `math`:
- Degrees -> radians: `rad = math.radians(deg)`
- `math.cos(rad)`, `math.sin(rad)`
- Use NO `App.cos`, `App.sin`, `App.pi`, NO `App.Units.Quantity(...)`
- Use NO Vector.rotate()
- For bolt circles / circular patterns: ONLY this pattern:
rad = math.radians(deg)
x = r * math.cos(rad)
y = r * math.sin(rad)
pos = App.Vector(x, y, 0)
- For shape rotations: ONLY Shape.rotate(...), e.g.:
shp.rotate(App.Vector(0,0,0), App.Vector(0,0,1), deg)
IMPORTANT:
- Prefer stable primitives + cuts (cylinders/rings/boxes) and patterns.
- If an ellipse/oval is difficult: use a capsule slot (box + 2 cylinders) instead of an ellipse.
- No fillets if they often break: start without fillet, then optionally add as the last step.
- Do NOT use the variable name `step_path` in the FreeCAD script. The export path is strictly `out_path` (os.getcwd()/result.step) and is set only in the footer.
- Avoid `Base.*` types (e.g., Base.Placement/Base.Matrix). Use only Part shapes + Shape.rotate(...) + App.Vector(...).
=== LESSONS-LEARNED COMPLIANCE (HARD RULES) ===
You also receive a list "LESSONS LEARNED (persisted from previous runs)".
MANDATORY:
- Analyze these lessons BEFORE writing the script.
- Identify the listed root causes and suggestions.
- Your script MUST NOT contain any of those errors again.
IN PARTICULAR (not exhaustive):
- Never use the variable `step_path`.
- Never access variables before they are defined.
- Do NOT use FreeCAD APIs marked in lessons as "not available" or "version-incompatible" (e.g., makePolyline).
- Do NOT use keyword arguments for Part.makeSphere / Part.makeCylinder etc., if lessons forbid them.
- Do NOT use Base.Placement / Base.Matrix APIs if lessons list them as a root cause.
IMPORTANT:
If your script includes a known lesson error, your answer is INVALID,
even if it appears syntactically correct.
"""
# --- Undefined-name preflight (prevents NameError before FreeCAD run) ---
_ALLOWED_GLOBAL_NAMES = {
# mandatory imports
"App", "Part", "os", "json", "math",
# runner contract variables
"doc", "solid", "obj", "bb", "out_path",
# common loop / util names
"range", "len", "float", "int", "str", "min", "max", "abs", "sum",
# typical local names we allow if author defines them; (kept minimal)
}
class _NameUseCollector(ast.NodeVisitor):
def __init__(self):
self.assigned: set[str] = set()
self.used: list[tuple[str, int]] = [] # (name, lineno)
def visit_Import(self, node):
for alias in node.names:
if alias.asname:
self.assigned.add(alias.asname)
else:
# import X -> binds X
self.assigned.add(alias.name.split(".")[0])
def visit_ImportFrom(self, node):
for alias in node.names:
self.assigned.add(alias.asname or alias.name)
def visit_FunctionDef(self, node):
self.assigned.add(node.name)
for arg in node.args.args:
self.assigned.add(arg.arg)
for arg in getattr(node.args, "posonlyargs", []):
self.assigned.add(arg.arg)
for arg in node.args.kwonlyargs:
self.assigned.add(arg.arg)
if node.args.vararg:
self.assigned.add(node.args.vararg.arg)
if node.args.kwarg:
self.assigned.add(node.args.kwarg.arg)
self.generic_visit(node)
def visit_Assign(self, node):
for t in node.targets:
self._collect_assigned_target(t)
self.generic_visit(node)
def visit_AnnAssign(self, node):
self._collect_assigned_target(node.target)
self.generic_visit(node)
def visit_AugAssign(self, node):
self._collect_assigned_target(node.target)
self.generic_visit(node)
def visit_For(self, node):
self._collect_assigned_target(node.target)
self.generic_visit(node)
def visit_With(self, node):
for item in node.items:
if item.optional_vars is not None:
self._collect_assigned_target(item.optional_vars)
self.generic_visit(node)
def visit_ExceptHandler(self, node):
if node.name:
self.assigned.add(node.name)
self.generic_visit(node)
def visit_Name(self, node):
# record usage sites for Load
if isinstance(node.ctx, ast.Load):
self.used.append((node.id, getattr(node, "lineno", 0) or 0))
elif isinstance(node.ctx, (ast.Store, ast.Del)):
self.assigned.add(node.id)
def _collect_assigned_target(self, t):
if isinstance(t, ast.Name):
self.assigned.add(t.id)
elif isinstance(t, (ast.Tuple, ast.List)):
for elt in t.elts:
self._collect_assigned_target(elt)
# ignore attributes/subscripts (obj.x, a[i])
def validate_no_undefined_names(script: str) -> tuple[bool, list[str], list[str]]:
"""Return (ok, issues, undefined_names).
Detects variables used before being defined at module scope, which often leads to NameError
in FreeCAD runs. This is intentionally conservative and allows common builtins.
"""
if not isinstance(script, str) or not script.strip():
return False, ["script empty"], []
try:
tree = ast.parse(script)
except Exception as e:
# Syntax handled elsewhere; keep this non-blocking but visible
return False, [f"cannot parse script for undefined-name check: {type(e).__name__}: {e}"], []
c = _NameUseCollector()
c.visit(tree)
# Add Python builtins we allow (conservative)
allowed = set(_ALLOWED_GLOBAL_NAMES)
allowed.update(dir(__builtins__))
# Anything used (Load) that isn't assigned/imported/builtin is considered undefined.
undefined: dict[str, int] = {}
for name, lineno in c.used:
if name in allowed:
continue
if name in c.assigned:
continue
# ignore dunder names
if name.startswith("__") and name.endswith("__"):
continue
undefined.setdefault(name, lineno)
if not undefined:
return True, [], []
# Build readable issues
items = sorted(undefined.items(), key=lambda x: (x[1], x[0]))
undefined_names = [n for n, _ in items]
issues = [f"undefined name '{n}' used (line {ln})" for n, ln in items[:25]]
return False, issues, undefined_names
EVALUATOR_INSTRUCTIONS = """
You are a geometric CAD reviewer with vision.
You receive:
- a JSON payload (text)
- 4 images as PNG (iso/front/right/top)
Your task: Assess whether the CAD result matches the prompt.
IMPORTANT:
- Respond ONLY with valid JSON.
- Respond ONLY with EXACTLY these keys (no others!):
status, notes, issues, expected_dims, observed_dims, observed_bbox
- Return NO plan, NO run_id, NO script.
Format (exactly like this):
{
"status": "PASS" | "FAIL",
"notes": "<string>",
"issues": ["..."],
"expected_dims": null,
"observed_dims": null,
"observed_bbox": null
}
Notes:
- Use the payload (bbox/volume/render_bbox/render_volume) + images for plausibility checks.
- observed_bbox can mirror render_bbox or bbox from the payload.
- expected_dims can be e.g. {"radius":400,"thickness":5} (if derivable), otherwise null.
- observed_dims can be a rough estimate or null.
"""
REPLANNER_INSTRUCTIONS = """
You are a designer replanner.
You receive user_prompt + last_eval + last_geom_eval + last_arch_spec.
Your task: Correct the feature plan (not FreeCAD code).
Respond ONLY with valid JSON in the EXACT format of the ARCHITECT (same keys!).
Rules:
- run_id only [a-z0-9-], max 20.
- Make targeted changes: add missing features, correct dimensions, shift strategy if needed.
- IMPORTANT (Contract/API fails): If last_eval.notes or last_eval.issues point to script contract violations (e.g., "violates the runner contract", "forbidden token", "missing exact 4-line import block", "Sketcher", "PartDesign", "Import.export", "/tmp"), then this is NOT a geometry problem.
In that case you must simplify the feature plan so the implementer can robustly execute it using Part primitives + boolean cuts:
- prefer: cylinder/ring (cylinder minus inner cylinder), box, fuse, cut
- do not assume Sketches/PartDesign pads/pockets/holes/patterns in the plan
- describe elliptical/oval cutouts as a "capsule slot" (box + 2 cylinders)
- fillets only optional and as the last step (or omit entirely)
Goal: a plan that is stable in the pure `Part` workflow.
- Additional rule (geometry memory):
- `last_geom_eval` is the last evaluator feedback from a run that reached the evaluator (render/evaluate).
- If `last_eval` is a script/contract/runtime/render failure (i.e., NOT real geometry feedback), then for geometry corrections prefer `last_geom_eval` and use `last_eval` only for robustness/strategy changes.
Use last_failure_phase:
- IMPLEMENTATION_CONTRACT / CAD_RUNTIME / RENDER: simplify the feature plan (primitives + cuts), NO dimension changes.
- GEOMETRY_MISMATCH: correct dimensions / missing features.
- NONE: replan normally.
"""
# --- Failure phase classification helper ---
def classify_failure_phase(last_eval: dict) -> str:
"""
Classify the failure phase for targeted replanning.
Returns one of: "NONE", "IMPLEMENTATION_CONTRACT", "CAD_RUNTIME", "RENDER", "GEOMETRY_MISMATCH"
"""
if not last_eval:
return "NONE"
status = str(last_eval.get("status", "")).strip().upper()
if status == "PASS":
return "NONE"
notes = (last_eval.get("notes") or "").lower()
issues = [str(x).lower() for x in (last_eval.get("issues") or [])]
# Implementation contract errors
contract_keywords = ["contract", "runner", "import", "solid", "syntax"]
if any(any(k in (notes or "") for k in contract_keywords) or any(k in (iss or "") for k in contract_keywords) for iss in issues):
return "IMPLEMENTATION_CONTRACT"
# CAD runtime errors
cad_keywords = ["cad error", "exception", "runtime", "freecad"]
if any(any(k in (iss or "") for k in cad_keywords) for iss in issues):
return "CAD_RUNTIME"
# Render errors
if any("render" in (iss or "") for iss in issues):
return "RENDER"
# Geometry mismatch (default)
return "GEOMETRY_MISMATCH"
DEBUGGER_INSTRUCTIONS = """
You are a FreeCAD script debugger.
You receive stderr/stdout and the current prompt/plan.
Respond ONLY with JSON:
{
"root_cause": "<short>",
"fix_type": "api" | "strategy" | "params",
"suggestions": ["..."]
}
Rules:
- Do not output code.
- Focus: why the FreeCAD run failed.
- If the script contract is violated (wrong imports, PartDesign/Sketcher/Draft/Import used, export not via os.getcwd()/result.step, missing 'solid', missing Result line, wrong print(json.dumps(...))): then fix_type MUST be "api".
- If geometry/booleans are unstable: fix_type="strategy".
- If dimensions/parameters are nonsensical: fix_type="params".
- Provide concrete, actionable guidance for the designer replanner/implementer.
"""
REPAIRER_INSTRUCTIONS = """
You are a FreeCAD engineering repair agent.
Goal: minimally repair an EXISTING FreeCAD script so it runs again.
You must NOT generate a completely new script.
You receive:
- failure_class: one of: SYNTAX | UNDEFINED_NAME | CONTRACT | CAD_RUNTIME
- policy: allows/forbids certain patch scopes
- script: the current FreeCAD script (as a string)
- errors: list of errors/issues (strings)
- stderr/stdout/root_cause_line (optional)
You MUST respond ONLY with valid JSON (no Markdown, no explanations):
{
"failure_class": "SYNTAX|UNDEFINED_NAME|CONTRACT|CAD_RUNTIME",
"edits": [
{
"op": "replace",
"old": "<exact substring to replace>",
"new": "<replacement>"
},
{
"op": "insert_after",
"anchor": "<exact substring anchor>",
"text": "<text to insert after anchor>"
},
{
"op": "delete",
"old": "<exact substring to delete>"
}
],
"notes": "<short>"
}
PATCH POLICY (HARD):
A) failure_class = SYNTAX or UNDEFINED_NAME
- Allowed: only minimal text edits that fix syntax/indent/imports/undefined names.
- Forbidden: change geometry/parameters/feature strategy (no new features, no dimension changes).
- Typical fixes: fix indentation, define/replace variables, correct wrong names, fix missing/extra brackets/quotes.
B) failure_class = CONTRACT
- Allowed: only contract glue fixes (imports/doc/footer/export/print), no geometry changes.
C) failure_class = CAD_RUNTIME
- Allowed: small strategy fixes, but ONLY locally:
- Change boolean order (e.g., fuse tools and cut once)
- Slightly increase margin/clearance (e.g., +0.2mm) to stabilize BOP
- Simplify a problematic operation (e.g., multiple cuts instead of complex fuse)
- Forbidden: reinvent geometry or completely change dimensions.
IMPORTANT:
- Use only ops replace/insert_after/delete.
- `old` and `anchor` must exist exactly in the script.
- Keep `edits` as small as possible (max 12 edits).
"""
# ----------------------------
# Helpers
# ----------------------------
REQUIRED_EVAL_KEYS = {"status", "notes", "issues", "expected_dims", "observed_dims", "observed_bbox"}
REQUIRED_IMPL_KEYS = {"run_id", "plan", "script"}
REQUIRED_ARCH_KEYS = {"run_id", "intent", "params", "datums", "feature_plan", "acceptance", "plan"}
REQUIRED_DBG_KEYS = {"root_cause", "fix_type", "suggestions"}
REQUIRED_REPAIR_KEYS = {"failure_class", "edits", "notes"}
def normalize_repair(obj: dict) -> dict | None:
if not isinstance(obj, dict):
return None
if not REQUIRED_REPAIR_KEYS.issubset(set(obj.keys())):
return None
fc = str(obj.get("failure_class") or "").strip().upper()
if fc not in ("SYNTAX", "UNDEFINED_NAME", "CONTRACT", "CAD_RUNTIME"):
return None
edits = obj.get("edits")
if not isinstance(edits, list):
return None
norm_edits: list[dict] = []
for e in edits[:12]:
if not isinstance(e, dict):
continue
op = str(e.get("op") or "").strip().lower()
if op == "replace":
old = e.get("old")
new = e.get("new")
if isinstance(old, str) and old and isinstance(new, str):
norm_edits.append({"op": "replace", "old": old, "new": new})
elif op == "insert_after":
anchor = e.get("anchor")
text = e.get("text")
if isinstance(anchor, str) and anchor and isinstance(text, str):
norm_edits.append({"op": "insert_after", "anchor": anchor, "text": text})
elif op == "delete":
old = e.get("old")
if isinstance(old, str) and old:
norm_edits.append({"op": "delete", "old": old})
notes = obj.get("notes")
if not isinstance(notes, str):
notes = "" if notes is None else str(notes)
return {"failure_class": fc, "edits": norm_edits, "notes": notes}
def apply_script_edits(script: str, edits: list[dict]) -> tuple[str, list[str]]:
"""Apply structured minimal edits deterministically.
Returns (new_script, actions). If an edit cannot be applied (substring not found), it is skipped and recorded.
"""
actions: list[str] = []
s = script or ""
for i, e in enumerate(edits or [], start=1):
op = e.get("op")
if op == "replace":
old = e.get("old", "")
new = e.get("new", "")
if old in s:
s = s.replace(old, new, 1)
actions.append(f"edit#{i}: replace applied")
else:
actions.append(f"edit#{i}: replace skipped (old not found)")
elif op == "insert_after":
anchor = e.get("anchor", "")
text = e.get("text", "")
pos = s.find(anchor)
if pos != -1:
ins_at = pos + len(anchor)
s = s[:ins_at] + text + s[ins_at:]
actions.append(f"edit#{i}: insert_after applied")
else:
actions.append(f"edit#{i}: insert_after skipped (anchor not found)")
elif op == "delete":
old = e.get("old", "")
if old in s:
s = s.replace(old, "", 1)
actions.append(f"edit#{i}: delete applied")
else:
actions.append(f"edit#{i}: delete skipped (old not found)")
else:
actions.append(f"edit#{i}: unknown op skipped")
return s, actions
# --- Deterministic root-cause extractor (avoid debugger hallucinations) ---
_EXCEPTION_PATTERNS = (
r"^Traceback \(most recent call last\):$",
r"^(NameError|TypeError|AttributeError|ValueError|KeyError|IndexError|AssertionError|RuntimeError|NotImplementedError|ImportError|ModuleNotFoundError|OSError|IOError|Exception):\s+.*$",
r"^Exception while processing file:.*$",
)
_exception_re = re.compile("|".join(_EXCEPTION_PATTERNS), flags=re.MULTILINE)
def extract_root_cause_line(stderr_s: str, stdout_s: str) -> str:
"""Return the first meaningful exception/trace line from stderr/stdout.
This is used for logging/lessons so we don't learn from hallucinated debugger output.
"""
blob = "\n".join([(stderr_s or ""), (stdout_s or "")]).strip()
if not blob:
return ""
# Prefer the first explicit exception line (NameError/TypeError/...) if present.
for ln in blob.splitlines():
ln_s = (ln or "").strip()
if not ln_s:
continue
if re.match(_EXCEPTION_PATTERNS[1], ln_s):
return ln_s
# Otherwise capture the first traceback marker or processing exception.
m = _exception_re.search(blob)
if m:
return (m.group(0) or "").strip()
return ""
# --- Safe math expression repair for JSON ---
import ast
import operator as _op
_ALLOWED_OPS = {
ast.Add: _op.add,
ast.Sub: _op.sub,
ast.Mult: _op.mul,
ast.Div: _op.truediv,
ast.FloorDiv: _op.floordiv,
ast.Mod: _op.mod,
ast.Pow: _op.pow,
ast.USub: _op.neg,
ast.UAdd: _op.pos,
}
def _safe_eval_arith(expr: str) -> float | int:
"""Safely evaluate a tiny subset of arithmetic: numbers + - * / // % ** and parentheses."""
expr = (expr or "").strip()
if not expr:
raise ValueError("empty expr")
node = ast.parse(expr, mode="eval")
def _eval(n):
if isinstance(n, ast.Expression):
return _eval(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)):
return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _ALLOWED_OPS:
return _ALLOWED_OPS[type(n.op)](_eval(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _ALLOWED_OPS:
return _ALLOWED_OPS[type(n.op)](_eval(n.left), _eval(n.right))
raise ValueError(f"unsupported expr: {expr}")
return _eval(node)
def repair_json_arithmetic(text: str) -> str:
"""Replace simple arithmetic expressions used as JSON values with computed literals.
Example: {"distance": 112 + 30} -> {"distance": 142}
Only repairs expressions that:
- appear as a value after ':'
- contain only digits, decimal points, whitespace, and + - * / ( ) operators
- are directly followed by ',' or '}'
"""
if not text:
return text
pattern = re.compile(r":\s*([0-9\s\+\-\*\/\(\)\.]+)\s*(?=,|\})")
def _repl(m: re.Match) -> str:
expr = (m.group(1) or "").strip()
# Fast path: already a plain number
if re.fullmatch(r"[0-9]+(\.[0-9]+)?", expr):
return ": " + expr
try:
val = _safe_eval_arith(expr)
except Exception:
return m.group(0) # leave unchanged
# Emit int if it's effectively an int
if isinstance(val, float) and abs(val - int(val)) < 1e-9:
val = int(val)
return ": " + str(val)
return pattern.sub(_repl, text)
def extract_json(text: str):
text = (text or "").strip()
# First attempt: direct parse
try:
return json.loads(text)
except Exception:
pass
# Second attempt: find first JSON-like block
m = re.search(r"\{.*\}", text, flags=re.DOTALL)
if not m:
# Last resort: try to repair the whole text anyway
repaired = repair_json_arithmetic(text)
return json.loads(repaired)
blob = m.group(0)
# Try raw blob
try:
return json.loads(blob)
except Exception:
# Try repaired blob (fixes things like `112 + 30`)
repaired = repair_json_arithmetic(blob)
return json.loads(repaired)
def sanitize_run_id(s):
s = (s or "").lower()
s = re.sub(r"[^a-z0-9-]+", "-", s).strip("-")
s = (s[:20] or "run").strip("-")
return s or "run"
def normalize_eval(obj: dict) -> dict | None:
"""Accept evaluator answers even if they have extra keys; return only required schema."""
if not isinstance(obj, dict):
return None
if not REQUIRED_EVAL_KEYS.issubset(set(obj.keys())):
return None
status = str(obj.get("status", "")).strip().upper()
if status not in ("PASS", "FAIL"):
return None
notes = obj.get("notes")
issues = obj.get("issues")
if not isinstance(notes, str):
notes = str(notes) if notes is not None else ""
if not isinstance(issues, list):
issues = [str(issues)] if issues is not None else []
return {
"status": status,
"notes": notes,
"issues": issues,
"expected_dims": obj.get("expected_dims"),
"observed_dims": obj.get("observed_dims"),
"observed_bbox": obj.get("observed_bbox"),
}
# --- New helpers for implementer/architect schema ---
def normalize_impl(obj: dict) -> dict | None:
"""Return a minimal valid implementer spec (run_id, plan[list[str]], script[str]) or None."""
if not isinstance(obj, dict):
return None
if not REQUIRED_IMPL_KEYS.issubset(set(obj.keys())):
return None
run_id = sanitize_run_id(obj.get("run_id", "run"))
plan_val = obj.get("plan", [])
if isinstance(plan_val, list):
plan = [p if isinstance(p, str) else json.dumps(p, ensure_ascii=False) for p in plan_val]
else:
plan = [str(plan_val)]
script = obj.get("script")
if not isinstance(script, str) or not script.strip():
return None
return {"run_id": run_id, "plan": plan, "script": script}
def looks_like_architect(obj: dict) -> bool:
if not isinstance(obj, dict):
return False
keys = set(obj.keys())
# architect outputs feature_plan/acceptance/params etc.
return ("feature_plan" in keys) or REQUIRED_ARCH_KEYS.issubset(keys)
# --- FreeCAD script contract validator ---
def validate_freecad_script(script: str) -> tuple[bool, list[str]]:
"""Hard-validate that the script matches the strict FreeCAD runner contract.
This prevents wasting CAD runs on scripts that will definitely fail (wrong imports,
wrong export path, missing required footer, etc.).
"""
issues: list[str] = []
if not isinstance(script, str) or not script.strip():
return False, ["script empty"]
s = script.strip().replace("\r\n", "\n")
s_lower = s.lower()
required_import_block = (
"import FreeCAD as App\n"
"import Part\n"
"import os, json\n"
"import math\n"
)
# Must start with the exact 4-line import block
if not s.startswith(required_import_block):
issues.append("script must start with exact 4-line import block (App/Part/os,json/math)")
# Required mandatory lines/fragments
if 'doc = App.newDocument("Model")' not in s:
issues.append('missing: doc = App.newDocument("Model")')
# Require an actual assignment to `solid` somewhere in the body.
# (A mere mention like "obj.Shape = solid" is not enough.)
if not re.search(r"^\s*solid\s*=", s, flags=re.MULTILINE):
issues.append("missing: assignment to variable 'solid' (final shape)")
if 'obj = doc.addObject("Part::Feature","Result"); obj.Shape = solid; doc.recompute()' not in s:
issues.append("missing: exact Result object creation line")
if 'out_path = os.path.join(os.getcwd(),"result.step")' not in s:
issues.append("missing: out_path using os.getcwd() and result.step")
if 'obj.Shape.exportStep(out_path)' not in s:
issues.append("missing: obj.Shape.exportStep(out_path)")
required_print = 'print(json.dumps({"checks": {"bbox": [bb.XMin,bb.YMin,bb.ZMin,bb.XMax,bb.YMax,bb.ZMax], "volume": float(obj.Shape.Volume)}}))'
if required_print not in s:
issues.append("missing: exact final print(json.dumps({checks:{bbox,volume}})) line")
if 'bb = obj.Shape.BoundBox' not in s:
issues.append('missing: bb = obj.Shape.BoundBox')
# Forbidden imports / paths / modules
forbidden_tokens = [
"import FreeCAD,",
"from FreeCAD",
"Sketcher",
"Draft",
"PartDesign",
"Import",
"export(__objs__",
"/tmp/",
"import os\\nimport json",
"import json\\nimport os",
"import os, json,",
"import os,json",
"Part.newDocument",
"step_path",
"Base",
"makePolyline",
"reduce(",
"functools",
]
for tok in forbidden_tokens:
if tok in s:
issues.append(f"forbidden token present: {tok}")
# --- Additional hard checks ---
# 1. Part.newDocument or Part.newdocument
if "Part.newDocument" in s or "Part.newdocument" in s:
issues.append("forbidden: Part.newDocument (must use App.newDocument)")
# 2. step_path anywhere (case-insensitive)
if "step_path" in s_lower:
issues.append("forbidden: step_path (must use out_path footer only)")
# 3. Base. or standalone Base (case-insensitive)
if "Base." in s or re.search(r"\bBase\b", s, flags=re.IGNORECASE):
issues.append("forbidden: Base namespace (must avoid Base.* and use App.Vector + Shape.rotate)")
# 4. makePolyline (case-insensitive)
if re.search(r"makepolyline", s, flags=re.IGNORECASE):
issues.append("forbidden: Part.makePolyline (not available; use Part.makePolygon / edges)")
# 5. Regex for keyword-args in Part primitives
primitive_regexes = [
(r"Part\.makeSphere\s*\(.*\w+\s*=", "forbidden: keyword args in Part.makeSphere(...)"),
(r"Part\.makeCylinder\s*\(.*\w+\s*=", "forbidden: keyword args in Part.makeCylinder(...)"),
(r"Part\.makeCone\s*\(.*\w+\s*=", "forbidden: keyword args in Part.makeCone(...)"),
(r"Part\.makeTorus\s*\(.*\w+\s*=", "forbidden: keyword args in Part.makeTorus(...)"),
]
for pat, msg in primitive_regexes:
if re.search(pat, s):
issues.append(msg)
# 6. Multiple out_path = assignments
if s.count("out_path =") > 1:
issues.append("out_path assigned multiple times")
# Guard: out_path must not be referenced before the footer defines it.
# If a generated script uses out_path earlier (e.g. alternate export), it will NameError
# once we strip/replace exports.
first_out_assign = s.find('out_path = os.path.join(os.getcwd(),"result.step")')
if first_out_assign != -1:
pre = s[:first_out_assign]
if re.search(r"\bout_path\b", pre):
issues.append("out_path referenced before footer assignment")
# 7. Forbid common hallucinated identifiers that frequently cause NameError
hallucinated_idents = [
"part_obj",
"outer_circle",
"plate",
"hole_pt",
"single_hole",
"base_cylinder",
"hole_cyl",
"hole_cylinder",
]
if re.search(r"\b(" + "|".join(hallucinated_idents) + r")\b", s):
issues.append("forbidden: hallucinated identifier used (causes NameError); build shapes inline and keep only 'solid' + local 'tool'")
return (len(issues) == 0), issues
def validate_python_syntax(script: str) -> tuple[bool, str]:
"""Return (ok, error_message). Catches SyntaxError/IndentationError early."""
if not isinstance(script, str) or not script.strip():
return False, "script empty"
try:
compile(script, "<freecad_job>", "exec")
return True, ""
except (SyntaxError, IndentationError) as e:
# Format similar to Python trace: message (line:col)
msg = getattr(e, "msg", None) or str(e) or type(e).__name__
lineno = getattr(e, "lineno", None)
offset = getattr(e, "offset", None)
return False, f"{msg} (line {lineno}, col {offset})"
except Exception as e:
return False, f"{type(e).__name__}: {e}"
def _strip_unexpected_top_level_indent(lines: list[str], line_no_1based: int) -> bool:
"""If a top-level line has leading whitespace causing 'unexpected indent', strip it."""
idx = (line_no_1based or 0) - 1
if idx < 0 or idx >= len(lines):
return False
ln = lines[idx]
if not ln:
return False
# Only strip if it looks like accidental leading whitespace at top-level
if ln.startswith(" ") or ln.startswith("\t"):
lines[idx] = ln.lstrip(" \t")
return True
return False
def auto_fix_python_syntax(script: str) -> tuple[str, list[str]]:
"""Best-effort deterministic fixes for indentation/syntax issues introduced by generation.
- Replace tabs with 4 spaces.
- Strip trailing whitespace.
- If compile() reports 'unexpected indent', strip leading whitespace on the offending line.
Returns: (fixed_script, actions)
"""
actions: list[str] = []
if not isinstance(script, str):
return "", ["script not a string"]
s = script.replace("\r\n", "\n").replace("\t", " ")
if "\t" in script:
actions.append("replaced tabs with 4 spaces")
lines = [ln.rstrip() for ln in s.split("\n")]
# Try a few rounds of compile-based repair
for _ in range(3):
candidate = "\n".join(lines)
ok, err = validate_python_syntax(candidate)
if ok:
return candidate, actions
# Attempt targeted fix for 'unexpected indent'
if "unexpected indent" in err:
# Extract line number from the message "... (line N, col M)"
m = re.search(r"\(line\s+(\d+),\s+col\s+\d+\)", err)
ln_no = int(m.group(1)) if m else None
if ln_no and _strip_unexpected_top_level_indent(lines, ln_no):
actions.append(f"stripped leading whitespace on line {ln_no} (unexpected indent)")