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, ""