solidworks-mcp-python 1.5.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.
- solidworks_mcp/__init__.py +57 -0
- solidworks_mcp/adapters/__init__.py +40 -0
- solidworks_mcp/adapters/base.py +1882 -0
- solidworks_mcp/adapters/circuit_breaker.py +2052 -0
- solidworks_mcp/adapters/com_executor.py +252 -0
- solidworks_mcp/adapters/complexity_analyzer.py +272 -0
- solidworks_mcp/adapters/connection_pool.py +1884 -0
- solidworks_mcp/adapters/factory.py +366 -0
- solidworks_mcp/adapters/intelligent_router.py +218 -0
- solidworks_mcp/adapters/mock_adapter.py +4016 -0
- solidworks_mcp/adapters/pywin32_adapter.py +2842 -0
- solidworks_mcp/adapters/solidworks/__init__.py +17 -0
- solidworks_mcp/adapters/solidworks/bitmap.py +180 -0
- solidworks_mcp/adapters/solidworks/com_utils.py +56 -0
- solidworks_mcp/adapters/solidworks/dimension_layout.py +753 -0
- solidworks_mcp/adapters/solidworks/documentation.py +1476 -0
- solidworks_mcp/adapters/solidworks/drawing_standard.py +1993 -0
- solidworks_mcp/adapters/solidworks/face_selection.py +392 -0
- solidworks_mcp/adapters/solidworks/features.py +3362 -0
- solidworks_mcp/adapters/solidworks/io.py +3649 -0
- solidworks_mcp/adapters/solidworks/rebuild_diagnostics.py +269 -0
- solidworks_mcp/adapters/solidworks/rebuild_inspector.py +146 -0
- solidworks_mcp/adapters/solidworks/selection.py +308 -0
- solidworks_mcp/adapters/solidworks/sketch.py +2545 -0
- solidworks_mcp/adapters/sw_feature_errors.py +66 -0
- solidworks_mcp/adapters/sw_type_info.py +317 -0
- solidworks_mcp/adapters/vba_adapter.py +318 -0
- solidworks_mcp/adapters/vba_macro_executor.py +215 -0
- solidworks_mcp/agents/__init__.py +68 -0
- solidworks_mcp/agents/design_knowledge/assembly_patterns.md +127 -0
- solidworks_mcp/agents/design_knowledge/hinges.md +118 -0
- solidworks_mcp/agents/design_knowledge/snap_fits.md +93 -0
- solidworks_mcp/agents/design_knowledge/solidworks_basics.md +3 -0
- solidworks_mcp/agents/design_knowledge/tolerancing.md +128 -0
- solidworks_mcp/agents/harness.py +216 -0
- solidworks_mcp/agents/history_db.py +1508 -0
- solidworks_mcp/agents/retrieval_index.py +164 -0
- solidworks_mcp/agents/schemas.py +165 -0
- solidworks_mcp/agents/smoke_test.py +285 -0
- solidworks_mcp/agents/soc_exporter.py +650 -0
- solidworks_mcp/agents/soc_pickup.py +332 -0
- solidworks_mcp/agents/soc_rewind.py +221 -0
- solidworks_mcp/agents/vector_rag.py +665 -0
- solidworks_mcp/cache/__init__.py +5 -0
- solidworks_mcp/cache/response_cache.py +164 -0
- solidworks_mcp/config.py +729 -0
- solidworks_mcp/exceptions.py +37 -0
- solidworks_mcp/security/__init__.py +68 -0
- solidworks_mcp/security/auth.py +128 -0
- solidworks_mcp/security/cors.py +27 -0
- solidworks_mcp/security/rate_limiting.py +90 -0
- solidworks_mcp/security/runtime.py +155 -0
- solidworks_mcp/server.py +777 -0
- solidworks_mcp/server_cli_fixed.py +120 -0
- solidworks_mcp/setup/__init__.py +1 -0
- solidworks_mcp/setup/cli.py +399 -0
- solidworks_mcp/setup/clients.py +157 -0
- solidworks_mcp/setup/config_file.py +230 -0
- solidworks_mcp/setup/handshake.py +202 -0
- solidworks_mcp/setup/launcher.py +102 -0
- solidworks_mcp/setup/legacy.py +100 -0
- solidworks_mcp/setup/registration.py +193 -0
- solidworks_mcp/setup/solidworks.py +49 -0
- solidworks_mcp/tools/__init__.py +64 -0
- solidworks_mcp/tools/analysis.py +723 -0
- solidworks_mcp/tools/automation.py +578 -0
- solidworks_mcp/tools/docs_discovery.py +1476 -0
- solidworks_mcp/tools/drawing.py +1015 -0
- solidworks_mcp/tools/drawing_analysis.py +456 -0
- solidworks_mcp/tools/drawing_automation.py +627 -0
- solidworks_mcp/tools/engineering_data.py +466 -0
- solidworks_mcp/tools/error_classifier.py +232 -0
- solidworks_mcp/tools/export.py +833 -0
- solidworks_mcp/tools/file_facts.py +40 -0
- solidworks_mcp/tools/file_management.py +1473 -0
- solidworks_mcp/tools/input_compat.py +17 -0
- solidworks_mcp/tools/macro_recording.py +464 -0
- solidworks_mcp/tools/modeling.py +2360 -0
- solidworks_mcp/tools/sketching.py +1422 -0
- solidworks_mcp/tools/template_management.py +367 -0
- solidworks_mcp/tools/tool_boundary.py +280 -0
- solidworks_mcp/tools/vba_generation.py +1145 -0
- solidworks_mcp/ui/__init__.py +9 -0
- solidworks_mcp/ui/local_llm.py +725 -0
- solidworks_mcp/ui/routers/__init__.py +10 -0
- solidworks_mcp/ui/routers/checkpoint.py +24 -0
- solidworks_mcp/ui/routers/docs.py +47 -0
- solidworks_mcp/ui/routers/llm.py +83 -0
- solidworks_mcp/ui/routers/local_model.py +69 -0
- solidworks_mcp/ui/routers/model.py +75 -0
- solidworks_mcp/ui/routers/preview.py +38 -0
- solidworks_mcp/ui/routers/session.py +161 -0
- solidworks_mcp/ui/routers/viewer.py +154 -0
- solidworks_mcp/ui/schemas.py +502 -0
- solidworks_mcp/ui/server.py +298 -0
- solidworks_mcp/ui/service.py +481 -0
- solidworks_mcp/ui/services/__init__.py +136 -0
- solidworks_mcp/ui/services/_utils.py +958 -0
- solidworks_mcp/ui/services/checkpoint_service.py +1110 -0
- solidworks_mcp/ui/services/docs_service.py +282 -0
- solidworks_mcp/ui/services/llm_service.py +956 -0
- solidworks_mcp/ui/services/model_service.py +459 -0
- solidworks_mcp/ui/services/parameter_repair_service.py +314 -0
- solidworks_mcp/ui/services/preview_service.py +489 -0
- solidworks_mcp/ui/services/session_service.py +1389 -0
- solidworks_mcp/utils/__init__.py +9 -0
- solidworks_mcp/utils/feature_tree_classifier.py +342 -0
- solidworks_mcp/utils/logging.py +76 -0
- solidworks_mcp/utils/validation.py +77 -0
- solidworks_mcp/version.py +8 -0
- solidworks_mcp_python-1.5.0.dist-info/METADATA +410 -0
- solidworks_mcp_python-1.5.0.dist-info/RECORD +115 -0
- solidworks_mcp_python-1.5.0.dist-info/WHEEL +4 -0
- solidworks_mcp_python-1.5.0.dist-info/entry_points.txt +3 -0
- solidworks_mcp_python-1.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
"""Shared utility functions for the Prefab CAD assistant dashboard services.
|
|
2
|
+
|
|
3
|
+
This module provides pure utility functions with no service-level dependencies.
|
|
4
|
+
All functions are stateless and accept their inputs as arguments.
|
|
5
|
+
|
|
6
|
+
Design principles applied:
|
|
7
|
+
- Single Responsibility: each function does exactly one thing.
|
|
8
|
+
- No side effects: these helpers never write to the database directly.
|
|
9
|
+
- No circular imports: this module is the lowest layer; it imports only from
|
|
10
|
+
the database and schema layers, never from sibling service modules.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import binascii
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from html.parser import HTMLParser
|
|
20
|
+
from importlib import import_module
|
|
21
|
+
from io import BytesIO
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import urlparse
|
|
25
|
+
from urllib.request import Request, urlopen
|
|
26
|
+
|
|
27
|
+
from ...agents.history_db import (
|
|
28
|
+
get_design_session,
|
|
29
|
+
insert_tool_call_record,
|
|
30
|
+
upsert_design_session,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Module-level defaults (mirrored from the top-level service constants so
|
|
35
|
+
# that utility helpers can be used in isolation during testing).
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
DEFAULT_SESSION_ID = "prefab-dashboard"
|
|
38
|
+
DEFAULT_USER_GOAL = "Design a printable mounting component with documented constraints and fastener strategy."
|
|
39
|
+
DEFAULT_SOURCE_MODE = "prompt"
|
|
40
|
+
DEFAULT_API_ORIGIN = os.getenv("SOLIDWORKS_UI_API_ORIGIN", "http://127.0.0.1:8766")
|
|
41
|
+
DEFAULT_PREVIEW_ORIENTATION = "current"
|
|
42
|
+
DEFAULT_WORKFLOW_MODE = "unselected"
|
|
43
|
+
# Default RAG index directory
|
|
44
|
+
DEFAULT_RAG_DIR = Path(".solidworks_mcp") / "rag"
|
|
45
|
+
SUPPORTED_MODEL_UPLOAD_SUFFIXES = frozenset({".sldprt", ".sldasm", ".slddrw"})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# JSON helpers
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_json_blob(payload: str | None) -> dict[str, Any]:
|
|
54
|
+
"""Parse a JSON string into a dict, returning an empty dict on any failure.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
payload: Raw JSON string, or ``None``.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Parsed dict, or ``{}`` if parsing fails.
|
|
61
|
+
"""
|
|
62
|
+
if not payload:
|
|
63
|
+
return {}
|
|
64
|
+
try:
|
|
65
|
+
parsed = json.loads(payload)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
return {}
|
|
68
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sanitize_ui_text(value: Any, fallback: str = "") -> str:
|
|
72
|
+
"""Return a clean string from *value*, using *fallback* for empty/invalid inputs.
|
|
73
|
+
|
|
74
|
+
Strips template placeholders such as ``{{ field }}``, bare quotes, and
|
|
75
|
+
pydantic-ai expression strings that sometimes leak into UI state.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
value: Arbitrary input (str, None, etc.).
|
|
79
|
+
fallback: Value to return when *value* is empty or invalid.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Cleaned string, or *fallback*.
|
|
83
|
+
"""
|
|
84
|
+
if value is None:
|
|
85
|
+
return fallback
|
|
86
|
+
text = str(value).strip()
|
|
87
|
+
if not text:
|
|
88
|
+
return fallback
|
|
89
|
+
if text in {'"', "'"}:
|
|
90
|
+
return fallback
|
|
91
|
+
if text.startswith("{{") and text.endswith("}}"):
|
|
92
|
+
return fallback
|
|
93
|
+
if "$result." in text or "$error" in text:
|
|
94
|
+
return fallback
|
|
95
|
+
return text
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def sanitize_model_path_text(value: Any) -> str:
|
|
99
|
+
"""Strip surrounding quotes from a model path string.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
value: Raw model path value from UI state.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Cleaned path string, or ``""`` if empty.
|
|
106
|
+
"""
|
|
107
|
+
text = sanitize_ui_text(value, "")
|
|
108
|
+
if len(text) >= 2 and text[0] == text[-1] and text[0] in {'"', "'"}:
|
|
109
|
+
text = text[1:-1].strip()
|
|
110
|
+
return text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def sanitize_preview_viewer_url(
|
|
114
|
+
value: Any,
|
|
115
|
+
*,
|
|
116
|
+
session_id: str,
|
|
117
|
+
api_origin: str,
|
|
118
|
+
) -> str:
|
|
119
|
+
"""Validate and return a preview viewer URL, or ``""`` if it looks wrong.
|
|
120
|
+
|
|
121
|
+
Rejects URLs pointing at unexpected origins or session IDs to prevent
|
|
122
|
+
open-redirect-style issues in the embedded viewer iframe.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
value: Raw URL string from session metadata.
|
|
126
|
+
session_id: Expected session ID segment in the path.
|
|
127
|
+
api_origin: Allowed API origin (scheme + host + port).
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Validated URL string, or ``""`` if validation fails.
|
|
131
|
+
"""
|
|
132
|
+
text = sanitize_ui_text(value, "")
|
|
133
|
+
if not text:
|
|
134
|
+
return ""
|
|
135
|
+
parsed = urlparse(text)
|
|
136
|
+
path = (parsed.path or "").rstrip("/")
|
|
137
|
+
expected_path = f"/api/ui/viewer/{session_id}".rstrip("/")
|
|
138
|
+
if path != expected_path:
|
|
139
|
+
return ""
|
|
140
|
+
if parsed.scheme and parsed.netloc:
|
|
141
|
+
expected = urlparse(api_origin)
|
|
142
|
+
if parsed.netloc != expected.netloc:
|
|
143
|
+
return ""
|
|
144
|
+
return text
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Trace / debug helpers
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _trace_json_default(value: Any) -> str:
|
|
153
|
+
"""JSON serialisation default: convert unknown types to their str repr."""
|
|
154
|
+
return str(value)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def trace_json(value: Any) -> str:
|
|
158
|
+
"""Serialise *value* to a pretty-printed JSON string for operator trace panels.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
value: Any JSON-serialisable value.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Indented JSON string.
|
|
165
|
+
"""
|
|
166
|
+
return json.dumps(value, ensure_ascii=True, indent=2, default=_trace_json_default)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def trace_session_row(session_row: dict[str, Any] | None) -> dict[str, Any]:
|
|
170
|
+
"""Return a copy of *session_row* with the bulky ``metadata_json`` field removed.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
session_row: Raw session row dict from the database.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Filtered dict suitable for debug display.
|
|
177
|
+
"""
|
|
178
|
+
if not session_row:
|
|
179
|
+
return {}
|
|
180
|
+
return {key: value for key, value in session_row.items() if key != "metadata_json"}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def trace_tool_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
184
|
+
"""Return the last ten tool-call records as lean trace dicts.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
records: Full list of tool-call records for the session.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Trimmed list of trace-friendly dicts (id, tool_name, success, …).
|
|
191
|
+
"""
|
|
192
|
+
traced: list[dict[str, Any]] = []
|
|
193
|
+
for record in records[-10:]:
|
|
194
|
+
traced.append(
|
|
195
|
+
{
|
|
196
|
+
"id": record.get("id"),
|
|
197
|
+
"tool_name": record.get("tool_name"),
|
|
198
|
+
"success": record.get("success"),
|
|
199
|
+
"input_json": record.get("input_json"),
|
|
200
|
+
"output_json": record.get("output_json"),
|
|
201
|
+
"created_at": record.get("created_at"),
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
return traced
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Context / file path helpers
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def safe_context_name(context_name: str | None, session_id: str) -> str:
|
|
213
|
+
"""Normalise a context name to a safe filesystem slug.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
context_name: User-supplied context name, may be None.
|
|
217
|
+
session_id: Fallback identifier.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Alphanumeric + hyphen slug, never empty.
|
|
221
|
+
"""
|
|
222
|
+
base = (context_name or session_id or "prefab-dashboard").strip()
|
|
223
|
+
allowed = [ch if (ch.isalnum() or ch in {"-", "_"}) else "-" for ch in base]
|
|
224
|
+
normalized = "".join(allowed).strip("-")
|
|
225
|
+
return normalized or "prefab-dashboard"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Session metadata merge / persist helpers
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def merge_metadata(
|
|
234
|
+
session_id: str,
|
|
235
|
+
*,
|
|
236
|
+
db_path: Path | None = None,
|
|
237
|
+
user_goal: str | None = None,
|
|
238
|
+
**updates: Any,
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
"""Read session metadata, merge *updates* into it, and write it back.
|
|
241
|
+
|
|
242
|
+
Implements the optimistic read-modify-write pattern used across all
|
|
243
|
+
service functions that need to update one or more metadata keys without
|
|
244
|
+
overwriting unrelated keys.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
session_id: Target session identifier.
|
|
248
|
+
db_path: Optional override for the SQLite database path.
|
|
249
|
+
user_goal: When provided, also updates the ``user_goal`` column.
|
|
250
|
+
**updates: Arbitrary key-value pairs to merge into metadata.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
The merged metadata dict after the write.
|
|
254
|
+
"""
|
|
255
|
+
session_row = get_design_session(session_id, db_path=db_path)
|
|
256
|
+
metadata = parse_json_blob(session_row["metadata_json"]) if session_row else {}
|
|
257
|
+
metadata.update(updates)
|
|
258
|
+
|
|
259
|
+
effective_goal = user_goal or (
|
|
260
|
+
session_row["user_goal"] if session_row else DEFAULT_USER_GOAL
|
|
261
|
+
)
|
|
262
|
+
effective_source = (
|
|
263
|
+
session_row["source_mode"] if session_row else DEFAULT_SOURCE_MODE
|
|
264
|
+
)
|
|
265
|
+
effective_family = session_row["accepted_family"] if session_row else None
|
|
266
|
+
effective_status = session_row["status"] if session_row else "active"
|
|
267
|
+
effective_index = session_row["current_checkpoint_index"] if session_row else 0
|
|
268
|
+
|
|
269
|
+
upsert_design_session(
|
|
270
|
+
session_id=session_id,
|
|
271
|
+
user_goal=effective_goal,
|
|
272
|
+
source_mode=effective_source,
|
|
273
|
+
accepted_family=effective_family,
|
|
274
|
+
status=effective_status,
|
|
275
|
+
current_checkpoint_index=effective_index,
|
|
276
|
+
metadata_json=json.dumps(metadata, ensure_ascii=True),
|
|
277
|
+
db_path=db_path,
|
|
278
|
+
)
|
|
279
|
+
return metadata
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def persist_ui_action(
|
|
283
|
+
session_id: str,
|
|
284
|
+
*,
|
|
285
|
+
tool_name: str,
|
|
286
|
+
db_path: Path | None = None,
|
|
287
|
+
metadata_updates: dict[str, Any] | None = None,
|
|
288
|
+
user_goal: str | None = None,
|
|
289
|
+
input_payload: dict[str, Any] | None = None,
|
|
290
|
+
output_payload: dict[str, Any] | None = None,
|
|
291
|
+
output_metadata: bool = False,
|
|
292
|
+
success: bool = True,
|
|
293
|
+
checkpoint_id: int | None = None,
|
|
294
|
+
) -> dict[str, Any]:
|
|
295
|
+
"""Persist metadata updates and a matching tool-call audit record atomically.
|
|
296
|
+
|
|
297
|
+
Combines :func:`merge_metadata` and ``insert_tool_call_record`` so callers
|
|
298
|
+
can update session state and write an audit entry in a single call.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
session_id: Target session identifier.
|
|
302
|
+
tool_name: Logical name for the audit record (e.g. ``"ui.approve_brief"``).
|
|
303
|
+
db_path: Optional override for the SQLite database path.
|
|
304
|
+
metadata_updates: Key-value pairs to merge into session metadata.
|
|
305
|
+
user_goal: When provided, also updates the ``user_goal`` column.
|
|
306
|
+
input_payload: Dict serialised as the ``input_json`` audit column.
|
|
307
|
+
output_payload: Dict serialised as the ``output_json`` audit column.
|
|
308
|
+
output_metadata: When ``True``, write the merged metadata as ``output_json``.
|
|
309
|
+
success: Whether the action succeeded.
|
|
310
|
+
checkpoint_id: Optional FK to the associated plan checkpoint.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
The merged metadata dict after the write.
|
|
314
|
+
"""
|
|
315
|
+
merged_metadata: dict[str, Any] = {}
|
|
316
|
+
if metadata_updates is not None or user_goal is not None:
|
|
317
|
+
merged_metadata = merge_metadata(
|
|
318
|
+
session_id,
|
|
319
|
+
db_path=db_path,
|
|
320
|
+
user_goal=user_goal,
|
|
321
|
+
**(metadata_updates or {}),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
record_output = merged_metadata if output_metadata else output_payload
|
|
325
|
+
|
|
326
|
+
insert_tool_call_record(
|
|
327
|
+
session_id=session_id,
|
|
328
|
+
checkpoint_id=checkpoint_id,
|
|
329
|
+
tool_name=tool_name,
|
|
330
|
+
input_json=(
|
|
331
|
+
json.dumps(input_payload, ensure_ascii=True)
|
|
332
|
+
if input_payload is not None
|
|
333
|
+
else None
|
|
334
|
+
),
|
|
335
|
+
output_json=(
|
|
336
|
+
json.dumps(record_output, ensure_ascii=True)
|
|
337
|
+
if record_output is not None
|
|
338
|
+
else None
|
|
339
|
+
),
|
|
340
|
+
success=success,
|
|
341
|
+
db_path=db_path,
|
|
342
|
+
)
|
|
343
|
+
return merged_metadata
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
# Workflow copy strings
|
|
348
|
+
# ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def normalize_workflow_mode(workflow_mode: str | None) -> str:
|
|
352
|
+
"""Normalise a raw workflow mode string to a known value.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
workflow_mode: Raw mode string from UI state.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
``"edit_existing"``, ``"new_design"``, or ``DEFAULT_WORKFLOW_MODE``.
|
|
359
|
+
"""
|
|
360
|
+
normalized = (workflow_mode or DEFAULT_WORKFLOW_MODE).strip().lower()
|
|
361
|
+
if normalized in {"edit_existing", "new_design"}:
|
|
362
|
+
return normalized
|
|
363
|
+
return DEFAULT_WORKFLOW_MODE
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def workflow_copy(
|
|
367
|
+
workflow_mode: str, active_model_path: str | None = None
|
|
368
|
+
) -> tuple[str, str, str]:
|
|
369
|
+
"""Return display copy for the workflow selector.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
workflow_mode: Normalised workflow mode string.
|
|
373
|
+
active_model_path: Currently attached model path, if any.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Three-tuple of (label, guidance_text, flow_header_text).
|
|
377
|
+
"""
|
|
378
|
+
has_active_model = bool(str(active_model_path or "").strip())
|
|
379
|
+
if workflow_mode == "edit_existing":
|
|
380
|
+
return (
|
|
381
|
+
"Editing Existing Part or Assembly",
|
|
382
|
+
"Attach an existing SolidWorks file, inspect the feature tree, then describe the feature edits you want.",
|
|
383
|
+
"Choose Workflow -> Attach Model -> Inspect -> Plan -> Execute",
|
|
384
|
+
)
|
|
385
|
+
if workflow_mode == "new_design":
|
|
386
|
+
return (
|
|
387
|
+
"New Design From Scratch",
|
|
388
|
+
"Define the design goal and assumptions first, then inspect the proposed family before executing CAD steps.",
|
|
389
|
+
"Choose Workflow -> Define Goal -> Clarify -> Plan -> Execute",
|
|
390
|
+
)
|
|
391
|
+
if has_active_model:
|
|
392
|
+
return (
|
|
393
|
+
"Choose a Workflow",
|
|
394
|
+
"An active model is already attached. Pick whether you want to keep editing that file or pivot to a new design flow.",
|
|
395
|
+
"Choose Workflow -> Attach Model or Define Goal -> Inspect -> Plan -> Execute",
|
|
396
|
+
)
|
|
397
|
+
return (
|
|
398
|
+
"Choose a Workflow",
|
|
399
|
+
"Choose whether you are attaching an existing SolidWorks file or starting a new design from scratch.",
|
|
400
|
+
"Choose Workflow -> Configure -> Inspect/Clarify -> Plan -> Execute",
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
# Model name / provider helpers
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def provider_from_model_name(model_name: str) -> str:
|
|
410
|
+
"""Infer the provider prefix from a provider-qualified model name.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
model_name: Model name such as ``"github:openai/gpt-4.1"``.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
Provider string: ``"github"``, ``"openai"``, ``"anthropic"``,
|
|
417
|
+
``"local"``, or ``"custom"``.
|
|
418
|
+
"""
|
|
419
|
+
if model_name.startswith("github:"):
|
|
420
|
+
return "github"
|
|
421
|
+
if model_name.startswith("openai:"):
|
|
422
|
+
return "openai"
|
|
423
|
+
if model_name.startswith("anthropic:"):
|
|
424
|
+
return "anthropic"
|
|
425
|
+
if model_name.startswith("local:"):
|
|
426
|
+
return "local"
|
|
427
|
+
return "custom"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def default_model_for_profile(provider: str, profile: str) -> str:
|
|
431
|
+
"""Return the default model name for the given provider and profile tier.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
provider: Provider string (``"github"``, ``"local"``, etc.).
|
|
435
|
+
profile: Profile tier (``"small"``, ``"balanced"``, ``"large"``).
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
Provider-qualified model name string.
|
|
439
|
+
"""
|
|
440
|
+
normalized_profile = (profile or "balanced").lower()
|
|
441
|
+
if provider == "local":
|
|
442
|
+
profile_models = {
|
|
443
|
+
"small": "local:gemma4:e2b",
|
|
444
|
+
"balanced": "local:gemma4:e4b",
|
|
445
|
+
"large": "local:gemma4:26b",
|
|
446
|
+
}
|
|
447
|
+
return profile_models.get(normalized_profile, profile_models["balanced"])
|
|
448
|
+
|
|
449
|
+
profile_models = {
|
|
450
|
+
"small": "github:openai/gpt-4.1-mini",
|
|
451
|
+
"balanced": "github:openai/gpt-4.1",
|
|
452
|
+
"large": "github:openai/gpt-4.1",
|
|
453
|
+
}
|
|
454
|
+
return profile_models.get(normalized_profile, profile_models["balanced"])
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def normalize_model_name_for_provider(
|
|
458
|
+
model_name: str | None,
|
|
459
|
+
*,
|
|
460
|
+
provider: str | None,
|
|
461
|
+
profile: str | None = None,
|
|
462
|
+
) -> str:
|
|
463
|
+
"""Normalise a free-form model name into a provider-qualified routing string.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
model_name: Raw model name from UI state, may be None or unqualified.
|
|
467
|
+
provider: Explicit provider override (``"github"``, ``"local"``, etc.).
|
|
468
|
+
profile: Profile tier used as fallback when *model_name* is empty.
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
Provider-qualified model name (e.g. ``"github:openai/gpt-4.1"``).
|
|
472
|
+
"""
|
|
473
|
+
normalized_provider = (provider or "github").strip().lower()
|
|
474
|
+
normalized_profile = (profile or "balanced").strip().lower()
|
|
475
|
+
raw_model = sanitize_ui_text(model_name, "")
|
|
476
|
+
|
|
477
|
+
if not raw_model:
|
|
478
|
+
return default_model_for_profile(normalized_provider, normalized_profile)
|
|
479
|
+
|
|
480
|
+
# Already provider-qualified.
|
|
481
|
+
if ":" in raw_model:
|
|
482
|
+
return raw_model
|
|
483
|
+
|
|
484
|
+
if normalized_provider == "local":
|
|
485
|
+
return f"local:{raw_model}"
|
|
486
|
+
if normalized_provider == "github":
|
|
487
|
+
if "/" in raw_model:
|
|
488
|
+
return f"github:{raw_model}"
|
|
489
|
+
return f"github:openai/{raw_model}"
|
|
490
|
+
if normalized_provider in {"openai", "anthropic"}:
|
|
491
|
+
return f"{normalized_provider}:{raw_model}"
|
|
492
|
+
|
|
493
|
+
return raw_model
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def provider_has_credentials(
|
|
497
|
+
model_name: str, local_endpoint: str | None = None
|
|
498
|
+
) -> bool:
|
|
499
|
+
"""Check whether the required credentials exist for the given model.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
model_name: Provider-qualified model name.
|
|
503
|
+
local_endpoint: Local API endpoint URL (required for ``"local:"`` models).
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
``True`` if the relevant API key / endpoint is configured.
|
|
507
|
+
"""
|
|
508
|
+
provider = provider_from_model_name(model_name)
|
|
509
|
+
if provider == "github":
|
|
510
|
+
token = os.getenv("GITHUB_API_KEY") or os.getenv("GH_TOKEN")
|
|
511
|
+
return bool(token)
|
|
512
|
+
if provider == "openai":
|
|
513
|
+
return bool(os.getenv("OPENAI_API_KEY"))
|
|
514
|
+
if provider == "anthropic":
|
|
515
|
+
return bool(os.getenv("ANTHROPIC_API_KEY"))
|
|
516
|
+
if provider == "local":
|
|
517
|
+
return bool(local_endpoint)
|
|
518
|
+
return True
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ---------------------------------------------------------------------------
|
|
522
|
+
# Feature-tree helpers
|
|
523
|
+
# ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def normalize_feature_targets(feature_target_text: str | None) -> list[str]:
|
|
527
|
+
"""Parse a comma- or newline-separated feature target string into a list.
|
|
528
|
+
|
|
529
|
+
Strips ``@`` prefixes and filters out tokens that look like file paths.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
feature_target_text: Raw input such as ``"@Boss-Extrude1, @Sketch2"``.
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
List of normalised feature name strings.
|
|
536
|
+
"""
|
|
537
|
+
targets: list[str] = []
|
|
538
|
+
for raw in (feature_target_text or "").replace("\n", ",").split(","):
|
|
539
|
+
normalized = raw.strip()
|
|
540
|
+
if not normalized:
|
|
541
|
+
continue
|
|
542
|
+
candidate = normalized[1:] if normalized.startswith("@") else normalized
|
|
543
|
+
if _looks_like_path_token(candidate):
|
|
544
|
+
continue
|
|
545
|
+
targets.append(candidate)
|
|
546
|
+
return targets
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _looks_like_path_token(token: str) -> bool:
|
|
550
|
+
"""Return ``True`` if *token* looks like a filesystem path rather than a feature name."""
|
|
551
|
+
normalized = token.strip()
|
|
552
|
+
if not normalized:
|
|
553
|
+
return False
|
|
554
|
+
lowered = normalized.lower()
|
|
555
|
+
if len(normalized) >= 2 and normalized[1] == ":" and normalized[0].isalpha():
|
|
556
|
+
return True
|
|
557
|
+
if "\\" in normalized or "/" in normalized:
|
|
558
|
+
return True
|
|
559
|
+
if lowered.endswith(
|
|
560
|
+
(
|
|
561
|
+
".sldprt",
|
|
562
|
+
".sldasm",
|
|
563
|
+
".slddrw",
|
|
564
|
+
".step",
|
|
565
|
+
".stp",
|
|
566
|
+
".iges",
|
|
567
|
+
".igs",
|
|
568
|
+
".x_t",
|
|
569
|
+
".x_b",
|
|
570
|
+
)
|
|
571
|
+
):
|
|
572
|
+
return True
|
|
573
|
+
return False
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def feature_target_status(
|
|
577
|
+
features: list[dict[str, Any]], feature_target_text: str | None
|
|
578
|
+
) -> tuple[str, list[str], list[str]]:
|
|
579
|
+
"""Compute matched / missing feature target status.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
features: Feature tree rows from the active snapshot.
|
|
583
|
+
feature_target_text: Raw feature target input from the UI.
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
Three-tuple of (status_message, matched_names, missing_names).
|
|
587
|
+
"""
|
|
588
|
+
requested = normalize_feature_targets(feature_target_text)
|
|
589
|
+
if not requested:
|
|
590
|
+
if str(feature_target_text or "").strip():
|
|
591
|
+
return (
|
|
592
|
+
"No valid feature targets found. Use feature names such as @Boss-Extrude1 or @Sketch2. File paths are ignored.",
|
|
593
|
+
[],
|
|
594
|
+
[],
|
|
595
|
+
)
|
|
596
|
+
return ("No grounded feature target selected.", [], [])
|
|
597
|
+
|
|
598
|
+
available = {
|
|
599
|
+
str(feature.get("name") or "").strip().lower(): str(feature.get("name") or "")
|
|
600
|
+
for feature in features
|
|
601
|
+
if str(feature.get("name") or "").strip()
|
|
602
|
+
}
|
|
603
|
+
matched: list[str] = []
|
|
604
|
+
missing: list[str] = []
|
|
605
|
+
for target in requested:
|
|
606
|
+
hit = available.get(target.lower())
|
|
607
|
+
if hit:
|
|
608
|
+
matched.append(hit)
|
|
609
|
+
else:
|
|
610
|
+
missing.append(target)
|
|
611
|
+
|
|
612
|
+
if matched and not missing:
|
|
613
|
+
return (
|
|
614
|
+
"Grounded feature target(s): " + ", ".join(f"@{name}" for name in matched),
|
|
615
|
+
matched,
|
|
616
|
+
missing,
|
|
617
|
+
)
|
|
618
|
+
if matched:
|
|
619
|
+
return (
|
|
620
|
+
"Partially grounded target(s): "
|
|
621
|
+
+ ", ".join(f"@{name}" for name in matched)
|
|
622
|
+
+ " | Missing: "
|
|
623
|
+
+ ", ".join(f"@{name}" for name in missing),
|
|
624
|
+
matched,
|
|
625
|
+
missing,
|
|
626
|
+
)
|
|
627
|
+
return (
|
|
628
|
+
"No matching feature targets found for: "
|
|
629
|
+
+ ", ".join(f"@{name}" for name in missing),
|
|
630
|
+
matched,
|
|
631
|
+
missing,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def feature_grounding_warning_text(
|
|
636
|
+
*,
|
|
637
|
+
active_model_path: str,
|
|
638
|
+
feature_target_text: str,
|
|
639
|
+
feature_tree_count: int,
|
|
640
|
+
) -> str:
|
|
641
|
+
"""Return a warning string when feature grounding cannot proceed.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
active_model_path: Currently attached model path.
|
|
645
|
+
feature_target_text: Raw feature target refs from UI.
|
|
646
|
+
feature_tree_count: Number of rows in the current feature tree snapshot.
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
Warning message, or ``""`` if grounding is possible.
|
|
650
|
+
"""
|
|
651
|
+
if not active_model_path:
|
|
652
|
+
return ""
|
|
653
|
+
if not str(feature_target_text or "").strip():
|
|
654
|
+
return ""
|
|
655
|
+
if feature_tree_count > 0:
|
|
656
|
+
return ""
|
|
657
|
+
return (
|
|
658
|
+
"Grounding is unavailable for the current attached model context because "
|
|
659
|
+
"no feature tree rows were returned. Feature refs such as @Boss-Extrude1 "
|
|
660
|
+
"cannot be resolved until the adapter can read the active model tree."
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
# ---------------------------------------------------------------------------
|
|
665
|
+
# Directory helpers
|
|
666
|
+
# ---------------------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
_DEFAULT_PREVIEW_DIR = Path(".solidworks_mcp") / "ui_previews"
|
|
669
|
+
_DEFAULT_UPLOADED_MODEL_DIR = Path(".solidworks_mcp") / "ui_uploads"
|
|
670
|
+
_DEFAULT_CONTEXT_DIR = Path(".solidworks_mcp") / "ui_context"
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def ensure_preview_dir(preview_dir: Path | None = None) -> Path:
|
|
674
|
+
"""Create and return the preview image directory.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
preview_dir: Override directory; defaults to ``.solidworks_mcp/ui_previews``.
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
Resolved ``Path`` that is guaranteed to exist.
|
|
681
|
+
"""
|
|
682
|
+
resolved = preview_dir or _DEFAULT_PREVIEW_DIR
|
|
683
|
+
resolved.mkdir(parents=True, exist_ok=True)
|
|
684
|
+
return resolved
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def ensure_uploaded_model_dir(upload_dir: Path | None = None) -> Path:
|
|
688
|
+
"""Create and return the uploaded-model staging directory.
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
upload_dir: Override directory; defaults to ``.solidworks_mcp/ui_uploads``.
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
Resolved ``Path`` that is guaranteed to exist.
|
|
695
|
+
"""
|
|
696
|
+
resolved = upload_dir or _DEFAULT_UPLOADED_MODEL_DIR
|
|
697
|
+
resolved.mkdir(parents=True, exist_ok=True)
|
|
698
|
+
return resolved
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def ensure_context_dir(context_dir: Path | None = None) -> Path:
|
|
702
|
+
"""Create and return the dashboard context snapshot directory.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
context_dir: Override directory; defaults to ``.solidworks_mcp/ui_context``.
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
Resolved ``Path`` that is guaranteed to exist.
|
|
709
|
+
"""
|
|
710
|
+
resolved = context_dir or _DEFAULT_CONTEXT_DIR
|
|
711
|
+
resolved.mkdir(parents=True, exist_ok=True)
|
|
712
|
+
return resolved
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def context_file_path(
|
|
716
|
+
session_id: str,
|
|
717
|
+
*,
|
|
718
|
+
context_name: str | None = None,
|
|
719
|
+
context_dir: Path | None = None,
|
|
720
|
+
) -> Path:
|
|
721
|
+
"""Return the canonical path for a context snapshot JSON file.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
session_id: Session identifier used as the default filename.
|
|
725
|
+
context_name: Optional override name (will be slugified).
|
|
726
|
+
context_dir: Override directory for context files.
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
``Path`` pointing at ``<context_dir>/<safe_name>.json``.
|
|
730
|
+
"""
|
|
731
|
+
target_dir = ensure_context_dir(context_dir)
|
|
732
|
+
safe_name = safe_context_name(context_name, session_id)
|
|
733
|
+
return target_dir / f"{safe_name}.json"
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
# ---------------------------------------------------------------------------
|
|
737
|
+
# HTML text extractor (shared by docs fetching and URL reference reading)
|
|
738
|
+
# ---------------------------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
class HTMLTextExtractor(HTMLParser):
|
|
742
|
+
"""Minimal HTML-to-plain-text extractor.
|
|
743
|
+
|
|
744
|
+
Strips tags and collects visible text content. Script, style, and nav
|
|
745
|
+
elements are suppressed entirely.
|
|
746
|
+
"""
|
|
747
|
+
|
|
748
|
+
_SKIP_TAGS = frozenset({"script", "style", "nav", "footer", "head"})
|
|
749
|
+
|
|
750
|
+
def __init__(self) -> None:
|
|
751
|
+
super().__init__()
|
|
752
|
+
self._parts: list[str] = []
|
|
753
|
+
self._skip_depth = 0
|
|
754
|
+
|
|
755
|
+
def handle_starttag(self, tag: str, attrs: list) -> None:
|
|
756
|
+
if tag.lower() in self._SKIP_TAGS:
|
|
757
|
+
self._skip_depth += 1
|
|
758
|
+
|
|
759
|
+
def handle_endtag(self, tag: str) -> None:
|
|
760
|
+
if tag.lower() in self._SKIP_TAGS and self._skip_depth > 0:
|
|
761
|
+
self._skip_depth -= 1
|
|
762
|
+
|
|
763
|
+
def handle_data(self, data: str) -> None:
|
|
764
|
+
if self._skip_depth == 0:
|
|
765
|
+
stripped = data.strip()
|
|
766
|
+
if stripped:
|
|
767
|
+
self._parts.append(stripped)
|
|
768
|
+
|
|
769
|
+
def text(self) -> str:
|
|
770
|
+
"""Return the accumulated plain text.
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
Plain text content extracted from the HTML document.
|
|
774
|
+
"""
|
|
775
|
+
return "\n".join(self._parts)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
# ---------------------------------------------------------------------------
|
|
779
|
+
# Docs / reference helpers
|
|
780
|
+
# ---------------------------------------------------------------------------
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def filter_docs_text(text: str, query: str, *, max_chars: int = 4000) -> str:
|
|
784
|
+
"""Filter plain-text docs content to lines most relevant to *query*.
|
|
785
|
+
|
|
786
|
+
Returns up to ``max_chars`` characters of the most relevant lines, scored
|
|
787
|
+
by how many query tokens they contain.
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
text: Full plain-text docs content.
|
|
791
|
+
query: Space-separated keyword query.
|
|
792
|
+
max_chars: Maximum characters to return.
|
|
793
|
+
|
|
794
|
+
Returns:
|
|
795
|
+
Filtered and truncated text snippet.
|
|
796
|
+
"""
|
|
797
|
+
if not text:
|
|
798
|
+
return ""
|
|
799
|
+
tokens = {t.lower() for t in query.split() if t}
|
|
800
|
+
lines = text.splitlines()
|
|
801
|
+
scored: list[tuple[int, str]] = []
|
|
802
|
+
for line in lines:
|
|
803
|
+
lower = line.lower()
|
|
804
|
+
score = sum(1 for t in tokens if t in lower)
|
|
805
|
+
if score > 0:
|
|
806
|
+
scored.append((score, line))
|
|
807
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
808
|
+
selected = [line for _, line in scored]
|
|
809
|
+
combined = "\n".join(selected)
|
|
810
|
+
return combined[:max_chars]
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def is_url_reference(source_path: str) -> bool:
|
|
814
|
+
"""Return ``True`` when *source_path* is an http/https URL.
|
|
815
|
+
|
|
816
|
+
Args:
|
|
817
|
+
source_path: Raw source path string from the UI.
|
|
818
|
+
|
|
819
|
+
Returns:
|
|
820
|
+
``True`` if the path starts with http:// or https://.
|
|
821
|
+
"""
|
|
822
|
+
parsed = urlparse((source_path or "").strip())
|
|
823
|
+
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def read_reference_source(source_path: Path) -> str:
|
|
827
|
+
"""Read the text content of a local file (PDF, markdown, text).
|
|
828
|
+
|
|
829
|
+
PDF extraction requires the optional ``pypdf`` package.
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
source_path: Local file path to read.
|
|
833
|
+
|
|
834
|
+
Returns:
|
|
835
|
+
Extracted plain text content.
|
|
836
|
+
|
|
837
|
+
Raises:
|
|
838
|
+
RuntimeError: When a PDF is supplied but ``pypdf`` is not installed.
|
|
839
|
+
"""
|
|
840
|
+
try:
|
|
841
|
+
PdfReader = import_module("pypdf").PdfReader
|
|
842
|
+
except ImportError:
|
|
843
|
+
PdfReader = None
|
|
844
|
+
|
|
845
|
+
suffix = source_path.suffix.lower()
|
|
846
|
+
if suffix == ".pdf":
|
|
847
|
+
if PdfReader is None:
|
|
848
|
+
raise RuntimeError(
|
|
849
|
+
"Install pypdf to ingest PDF sources, or provide a text/markdown file instead."
|
|
850
|
+
)
|
|
851
|
+
reader = PdfReader(str(source_path))
|
|
852
|
+
return "\n\n".join((page.extract_text() or "") for page in reader.pages).strip()
|
|
853
|
+
|
|
854
|
+
return source_path.read_text(encoding="utf-8")
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def read_reference_url(source_url: str) -> tuple[str, str]:
|
|
858
|
+
"""Fetch content from a URL and return (text, label).
|
|
859
|
+
|
|
860
|
+
Handles HTML (stripped), PDF (requires ``pypdf``), and plain text.
|
|
861
|
+
|
|
862
|
+
Args:
|
|
863
|
+
source_url: http/https URL to fetch.
|
|
864
|
+
|
|
865
|
+
Returns:
|
|
866
|
+
Tuple of (plain_text_content, label) where label is derived from the URL path.
|
|
867
|
+
|
|
868
|
+
Raises:
|
|
869
|
+
RuntimeError: When a PDF is served but ``pypdf`` is not installed.
|
|
870
|
+
"""
|
|
871
|
+
try:
|
|
872
|
+
PdfReader = import_module("pypdf").PdfReader
|
|
873
|
+
except ImportError:
|
|
874
|
+
PdfReader = None
|
|
875
|
+
|
|
876
|
+
parsed = urlparse(source_url)
|
|
877
|
+
if parsed.scheme not in {"http", "https"}:
|
|
878
|
+
raise ValueError(f"Disallowed URL scheme: {parsed.scheme!r}")
|
|
879
|
+
request = Request(source_url, headers={"User-Agent": "SolidWorksMCP/1.0"})
|
|
880
|
+
with urlopen(request, timeout=20) as response: # nosec B310
|
|
881
|
+
content_type = response.headers.get_content_type()
|
|
882
|
+
charset = response.headers.get_content_charset() or "utf-8"
|
|
883
|
+
raw_bytes = response.read()
|
|
884
|
+
label = Path(parsed.path).name or parsed.netloc or source_url
|
|
885
|
+
suffix = Path(parsed.path).suffix.lower()
|
|
886
|
+
|
|
887
|
+
if content_type == "application/pdf" or suffix == ".pdf":
|
|
888
|
+
if PdfReader is None:
|
|
889
|
+
raise RuntimeError(
|
|
890
|
+
"Install pypdf to ingest PDF sources, or provide a text, markdown, or HTML source instead."
|
|
891
|
+
)
|
|
892
|
+
reader = PdfReader(BytesIO(raw_bytes))
|
|
893
|
+
text = "\n\n".join((page.extract_text() or "") for page in reader.pages).strip()
|
|
894
|
+
return text, label
|
|
895
|
+
|
|
896
|
+
decoded = raw_bytes.decode(charset, errors="ignore")
|
|
897
|
+
if "html" in content_type or suffix in {".html", ".htm"}:
|
|
898
|
+
parser = HTMLTextExtractor()
|
|
899
|
+
parser.feed(decoded)
|
|
900
|
+
return parser.text().strip(), label
|
|
901
|
+
|
|
902
|
+
return decoded.strip(), label
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
# ---------------------------------------------------------------------------
|
|
906
|
+
# Model upload helper
|
|
907
|
+
# ---------------------------------------------------------------------------
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def materialize_uploaded_model(
|
|
911
|
+
session_id: str,
|
|
912
|
+
uploaded_files: list[dict[str, Any]] | None,
|
|
913
|
+
*,
|
|
914
|
+
upload_dir: Path | None = None,
|
|
915
|
+
) -> Path:
|
|
916
|
+
"""Decode a base64-encoded uploaded model file and write it to the staging directory.
|
|
917
|
+
|
|
918
|
+
Args:
|
|
919
|
+
session_id: Dashboard session identifier used as a staging subdirectory.
|
|
920
|
+
uploaded_files: List of file payload dicts, each with ``name`` and ``data`` fields.
|
|
921
|
+
upload_dir: Override for the upload staging directory.
|
|
922
|
+
|
|
923
|
+
Returns:
|
|
924
|
+
Path pointing at the decoded file on disk.
|
|
925
|
+
|
|
926
|
+
Raises:
|
|
927
|
+
RuntimeError: When no file is provided, the name is missing, the suffix is
|
|
928
|
+
unsupported, or the data field is not valid base64.
|
|
929
|
+
"""
|
|
930
|
+
if not uploaded_files:
|
|
931
|
+
raise RuntimeError("No uploaded model file was provided.")
|
|
932
|
+
|
|
933
|
+
upload = uploaded_files[0]
|
|
934
|
+
file_name = Path(str(upload.get("name") or "")).name
|
|
935
|
+
if not file_name:
|
|
936
|
+
raise RuntimeError("Uploaded model is missing a filename.")
|
|
937
|
+
|
|
938
|
+
suffix = Path(file_name).suffix.lower()
|
|
939
|
+
if suffix not in SUPPORTED_MODEL_UPLOAD_SUFFIXES:
|
|
940
|
+
allowed = ", ".join(sorted(SUPPORTED_MODEL_UPLOAD_SUFFIXES))
|
|
941
|
+
raise RuntimeError(
|
|
942
|
+
f"Unsupported uploaded model type: {suffix or 'unknown'}. Expected one of {allowed}."
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
encoded_data = upload.get("data")
|
|
946
|
+
if not isinstance(encoded_data, str) or not encoded_data.strip():
|
|
947
|
+
raise RuntimeError("Uploaded model is missing file data.")
|
|
948
|
+
|
|
949
|
+
try:
|
|
950
|
+
file_bytes = base64.b64decode(encoded_data, validate=True)
|
|
951
|
+
except (binascii.Error, ValueError) as exc:
|
|
952
|
+
raise RuntimeError("Uploaded model payload is not valid base64 data.") from exc
|
|
953
|
+
|
|
954
|
+
target_dir = ensure_uploaded_model_dir(upload_dir) / session_id
|
|
955
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
956
|
+
target_path = target_dir / file_name
|
|
957
|
+
target_path.write_bytes(file_bytes)
|
|
958
|
+
return target_path
|