File size: 7,748 Bytes
11ba2bd | 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 | """LLM generation: build prompt, call HuggingFace Inference API, return code + explanation."""
from __future__ import annotations
from huggingface_hub import InferenceClient
from src.citations import Citation, build_context_block, extract_inline_refs, render_citation_markdown
from src.config import DEFAULT_MODEL
# ββ few-shot examples βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_FEW_SHOT_BOX = '''
### Example 1 β Parametric box with fillets
User: Create a parametric box width=50, height=30, depth=20 with 5mm fillets on vertical edges.
```python
import FreeCAD as App
import Part, Sketcher
doc = App.newDocument("ParametricBox")
body = doc.addObject("PartDesign::Body", "Body")
sketch = body.newObject("Sketcher::SketchObject", "RectSketch")
sketch.Support = (body.Origin.OriginFeatures[3], [""]) # XY_Plane
sketch.MapMode = "FlatFace"
width, depth, height, fillet_r = 50.0, 20.0, 30.0, 5.0
sketch.addGeometry(Part.LineSegment(App.Vector(0,0,0), App.Vector(width,0,0)), False)
sketch.addGeometry(Part.LineSegment(App.Vector(width,0,0), App.Vector(width,depth,0)), False)
sketch.addGeometry(Part.LineSegment(App.Vector(width,depth,0), App.Vector(0,depth,0)), False)
sketch.addGeometry(Part.LineSegment(App.Vector(0,depth,0), App.Vector(0,0,0)), False)
sketch.addConstraint(Sketcher.Constraint("Coincident", 0,2, 1,1))
sketch.addConstraint(Sketcher.Constraint("Coincident", 1,2, 2,1))
sketch.addConstraint(Sketcher.Constraint("Coincident", 2,2, 3,1))
sketch.addConstraint(Sketcher.Constraint("Coincident", 3,2, 0,1))
sketch.addConstraint(Sketcher.Constraint("Horizontal", 0))
sketch.addConstraint(Sketcher.Constraint("Horizontal", 2))
sketch.addConstraint(Sketcher.Constraint("Vertical", 1))
sketch.addConstraint(Sketcher.Constraint("Vertical", 3))
sketch.addConstraint(Sketcher.Constraint("DistanceX", 0, 1, 0, 2, width))
sketch.addConstraint(Sketcher.Constraint("DistanceY", 1, 1, 1, 2, depth))
doc.recompute()
pad = body.newObject("PartDesign::Pad", "Pad")
pad.Profile = sketch
pad.Length = height
doc.recompute()
fillet = body.newObject("PartDesign::Fillet", "Fillet")
fillet.Base = (pad, ["Edge1","Edge2","Edge3","Edge4"])
fillet.Radius = fillet_r
doc.recompute()
doc.saveAs("output.FCStd")
```
'''.strip()
_FEW_SHOT_REVOLVE = '''
### Example 2 β Revolved profile (cylinder / shaft)
User: Create a 20mm-diameter, 60mm-long cylindrical shaft using Revolution.
```python
import FreeCAD as App
import Part, Sketcher
doc = App.newDocument("Shaft")
body = doc.addObject("PartDesign::Body", "Body")
sketch = body.newObject("Sketcher::SketchObject", "Profile")
sketch.Support = (body.Origin.OriginFeatures[4], [""]) # XZ_Plane
sketch.MapMode = "FlatFace"
radius, length = 10.0, 60.0
sketch.addGeometry(Part.LineSegment(App.Vector(0,0,0), App.Vector(radius,0,0)), False)
sketch.addGeometry(Part.LineSegment(App.Vector(radius,0,0), App.Vector(radius,length,0)), False)
sketch.addGeometry(Part.LineSegment(App.Vector(radius,length,0), App.Vector(0,length,0)), False)
sketch.addGeometry(Part.LineSegment(App.Vector(0,length,0), App.Vector(0,0,0)), False)
sketch.addConstraint(Sketcher.Constraint("Coincident", 0,2, 1,1))
sketch.addConstraint(Sketcher.Constraint("Coincident", 1,2, 2,1))
sketch.addConstraint(Sketcher.Constraint("Coincident", 2,2, 3,1))
sketch.addConstraint(Sketcher.Constraint("Coincident", 3,2, 0,1))
sketch.addConstraint(Sketcher.Constraint("DistanceX", 0, 1, 0, 2, radius))
sketch.addConstraint(Sketcher.Constraint("DistanceY", 1, 1, 1, 2, length))
sketch.addConstraint(Sketcher.Constraint("PointOnObject", 0, 1, -1)) # origin on Y-axis
doc.recompute()
rev = body.newObject("PartDesign::Revolution", "Revolution")
rev.Profile = sketch
rev.ReferenceAxis = (sketch, ["V_Axis"])
rev.Angle = 360.0
doc.recompute()
doc.saveAs("output.FCStd")
```
'''.strip()
# ββ system prompt βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_SYSTEM_PROMPT = f"""You are an expert FreeCAD 1.1 Python scripting assistant specialised in \
parametric solid modelling with the PartDesign and Sketcher workbenches.
OUTPUT CONTRACT (strict):
1. Return ONE complete, self-contained Python script enclosed in a single ```python ... ``` block, \
runnable with `freecadcmd script.py`.
2. The script MUST:
- import FreeCAD as App, Part, Sketcher (never import PartDesignGui / FreeCADGui / SketcherGui β they crash headless)
- call App.newDocument(...)
- create a PartDesign::Body BEFORE any Sketch
- attach every Sketch to a standard plane from body.Origin.OriginFeatures (index 3=XY, 4=XZ, 5=YZ)
- call doc.recompute() after EVERY feature creation
- end with doc.saveAs("output.FCStd")
3. Use named variables for every dimension so the model is parametric.
4. Reference geometry by INDEX where possible (e.g. Sketcher.Constraint("Coincident", 0, 2, 1, 1)), \
NOT by topological name strings like "Face1" or "Edge3", to minimise Topological Naming Problem risk \
(mitigated but NOT eliminated in FreeCAD 1.0/1.1).
5. Add all dress-up features (Fillet, Chamfer, etc.) AFTER all additive/subtractive features.
6. After the code block, write one short paragraph explaining the key design decisions.
7. Cite the retrieved sources inline as [1], [2], etc. in comments and in the explanation.
8. End with a numbered citation list: `1. <Page Title> β <URL>`
KNOWN PITFALLS (never repeat these errors):
- Missing doc.recompute() β silent failure
- Mixing App.ActiveDocument and the captured doc variable
- Creating PartDesign features via doc.addObject instead of body.newObject
- Importing *Gui modules in headless scripts
{_FEW_SHOT_BOX}
{_FEW_SHOT_REVOLVE}
""".strip()
# ββ main entry point ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def generate_response(
query: str,
citations: list[Citation],
model: str = DEFAULT_MODEL,
) -> tuple[str, str, str]:
"""
Returns (code_block, explanation_md, error_msg).
code_block: the raw python code (no fences).
explanation_md: explanation + citations markdown.
error_msg: non-empty string on failure.
"""
if not citations:
return "", "", "No relevant documentation chunks were retrieved. Try broadening the query."
client = InferenceClient()
context = build_context_block(citations)
user_msg = f"RETRIEVED CONTEXT:\n{context}\n\nUSER REQUEST:\n{query}"
try:
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
temperature=0.2,
max_tokens=2500,
)
except Exception as exc: # noqa: BLE001
return "", "", f"HuggingFace API error: {exc}"
full_text = resp.choices[0].message.content or ""
# Split code block from rest of response
code_match = __import__("re").search(r"```python\n(.*?)```", full_text, __import__("re").DOTALL)
if code_match:
code = code_match.group(1).rstrip()
after_code = full_text[code_match.end():].strip()
else:
code = full_text
after_code = ""
used_ids = extract_inline_refs(full_text)
cite_md = render_citation_markdown(citations, used_ids or None)
explain = (after_code + "\n\n" + cite_md).strip()
return code, explain, ""
|