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,725 @@
|
|
|
1
|
+
"""Local LLM integration helpers for the SolidWorks MCP UI.
|
|
2
|
+
|
|
3
|
+
Provides three layers of typed abstraction:
|
|
4
|
+
|
|
5
|
+
1. **Hardware** – detect GPU VRAM / system RAM and pick the right Gemma tier. 2.
|
|
6
|
+
**Config** – ``LocalLLMConfig`` is the single source of truth for endpoint, model name,
|
|
7
|
+
and tier choice, shared by the UI, server endpoints, and the pydantic-ai agent runner.
|
|
8
|
+
3. **Agent runner** – ``run_local_agent()`` mirrors ``_run_structured_agent`` in
|
|
9
|
+
``service.py`` but routes exclusively to a local Ollama server. Both accept any
|
|
10
|
+
``BaseModel`` subclass as ``result_type`` so callers get a fully typed, validated
|
|
11
|
+
response regardless of which backend they use.
|
|
12
|
+
|
|
13
|
+
Model tiers (Gemma 4 family via Ollama's OpenAI-compatible API): small : gemma4:e2b
|
|
14
|
+
(~0-4 GB VRAM / CPU-capable) — edge and smoke tests balanced: gemma4:e4b (~8 GB VRAM) —
|
|
15
|
+
recommended default for local planning large : gemma4:26b (~18 GB VRAM) — workstation-
|
|
16
|
+
class local evaluation
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
from solidworks_mcp.ui.local_llm import probe_local_model, run_local_agent from
|
|
21
|
+
solidworks_mcp.agents.schemas import ClarificationResponse
|
|
22
|
+
|
|
23
|
+
probe = await probe_local_model() # LocalModelProbeResult result = await
|
|
24
|
+
run_local_agent( system_prompt="You are a SolidWorks CAD assistant.", user_prompt="How
|
|
25
|
+
many sketch constraints are needed for a slot?", result_type=ClarificationResponse,
|
|
26
|
+
config=probe.to_config(), )
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import platform
|
|
35
|
+
import subprocess
|
|
36
|
+
import urllib.parse
|
|
37
|
+
from typing import Any, Literal
|
|
38
|
+
|
|
39
|
+
from pydantic import BaseModel, Field
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Constants
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
OLLAMA_DEFAULT_ENDPOINT = "http://127.0.0.1:11434"
|
|
48
|
+
OLLAMA_OPENAI_ENDPOINT = f"{OLLAMA_DEFAULT_ENDPOINT}/v1"
|
|
49
|
+
|
|
50
|
+
_ALLOWED_SCHEMES = frozenset({"http", "https"})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _require_http_endpoint(url: str) -> None:
|
|
54
|
+
"""Raise ValueError if *url* does not use an http/https scheme.
|
|
55
|
+
|
|
56
|
+
Guards every ``urllib.request.urlopen`` call so that attacker-controlled
|
|
57
|
+
endpoint values cannot redirect requests to ``file://`` or other
|
|
58
|
+
non-network schemes (CWE-22 / Bandit B310).
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
url (str): The full URL (or endpoint prefix) to validate.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: When the parsed scheme is not ``http`` or ``https``.
|
|
65
|
+
"""
|
|
66
|
+
scheme = urllib.parse.urlparse(url).scheme.lower()
|
|
67
|
+
if scheme not in _ALLOWED_SCHEMES:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Ollama endpoint must use http or https, got scheme {scheme!r}."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Tier spec — typed model for each hardware tier
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class GemmaTierSpec(BaseModel):
|
|
79
|
+
"""Hardware and model metadata for a single Gemma inference tier.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
label (str): The label value.
|
|
83
|
+
min_ram_gb (float): The min ram gb value.
|
|
84
|
+
min_vram_gb (float): The min vram gb value.
|
|
85
|
+
ollama (str): The ollama value.
|
|
86
|
+
service (str): The service value.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
ollama: str = Field(description="Ollama model tag, e.g. 'gemma4:e4b'")
|
|
90
|
+
service: str = Field(description="service.py model string, e.g. 'local:gemma4:e4b'")
|
|
91
|
+
label: str = Field(description="Human-readable description shown in UI toasts")
|
|
92
|
+
min_vram_gb: float = Field(ge=0, description="Minimum GPU VRAM in GB (0 = CPU ok)")
|
|
93
|
+
min_ram_gb: float = Field(ge=0, description="Minimum system RAM in GB")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Gemma 3 / Gemma 4 model tiers — typed registry
|
|
97
|
+
GEMMA_TIERS: dict[str, GemmaTierSpec] = {
|
|
98
|
+
"small": GemmaTierSpec(
|
|
99
|
+
ollama="gemma4:e2b",
|
|
100
|
+
service="local:gemma4:e2b",
|
|
101
|
+
label="Gemma 4 E2B (small — CPU or 4 GB VRAM)",
|
|
102
|
+
min_vram_gb=0,
|
|
103
|
+
min_ram_gb=8,
|
|
104
|
+
),
|
|
105
|
+
"balanced": GemmaTierSpec(
|
|
106
|
+
ollama="gemma4:e4b",
|
|
107
|
+
service="local:gemma4:e4b",
|
|
108
|
+
label="Gemma 4 E4B (balanced — 8 GB VRAM)",
|
|
109
|
+
min_vram_gb=8,
|
|
110
|
+
min_ram_gb=16,
|
|
111
|
+
),
|
|
112
|
+
"large": GemmaTierSpec(
|
|
113
|
+
ollama="gemma4:26b",
|
|
114
|
+
service="local:gemma4:26b",
|
|
115
|
+
label="Gemma 4 26B (large — 18 GB VRAM)",
|
|
116
|
+
min_vram_gb=18,
|
|
117
|
+
min_ram_gb=32,
|
|
118
|
+
),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Shared config — single source of truth for LLM connection settings
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class LocalLLMConfig(BaseModel):
|
|
127
|
+
"""Runtime configuration for a local Ollama LLM connection.
|
|
128
|
+
|
|
129
|
+
Passed from the probe result into ``run_local_agent()`` or directly into
|
|
130
|
+
``_build_agent_model()`` in service.py to keep settings consistent across all layers (UI
|
|
131
|
+
state, server endpoints, pydantic-ai agent runner).
|
|
132
|
+
|
|
133
|
+
Attributes:
|
|
134
|
+
api_key (str): The api key value.
|
|
135
|
+
endpoint (str): The endpoint value.
|
|
136
|
+
ollama_model (str): The ollama model value.
|
|
137
|
+
openai_endpoint (str): The openai endpoint value.
|
|
138
|
+
service_model (str): The service model value.
|
|
139
|
+
tier (Literal["small", "balanced", "large"]): The tier value.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
endpoint: str = Field(
|
|
143
|
+
default=OLLAMA_DEFAULT_ENDPOINT,
|
|
144
|
+
description="Ollama base URL (without /v1 suffix)",
|
|
145
|
+
)
|
|
146
|
+
openai_endpoint: str = Field(
|
|
147
|
+
default=OLLAMA_OPENAI_ENDPOINT,
|
|
148
|
+
description="OpenAI-compatible endpoint for pydantic-ai",
|
|
149
|
+
)
|
|
150
|
+
tier: Literal["small", "balanced", "large"] = Field(default="balanced")
|
|
151
|
+
ollama_model: str = Field(default="gemma4:e4b")
|
|
152
|
+
service_model: str = Field(default="local:gemma4:e4b")
|
|
153
|
+
api_key: str = Field(
|
|
154
|
+
default="local",
|
|
155
|
+
description="API key sent to Ollama (ignored by Ollama but required by OpenAI client)",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def from_env(cls) -> LocalLLMConfig:
|
|
160
|
+
"""Build config from environment variables, falling back to defaults.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
LocalLLMConfig: The result produced by the operation.
|
|
164
|
+
"""
|
|
165
|
+
endpoint = os.getenv("SOLIDWORKS_UI_OLLAMA_ENDPOINT", OLLAMA_DEFAULT_ENDPOINT)
|
|
166
|
+
service_model = os.getenv("SOLIDWORKS_UI_MODEL", "local:gemma4:e4b")
|
|
167
|
+
tier = "small"
|
|
168
|
+
for t, spec in GEMMA_TIERS.items():
|
|
169
|
+
if spec.service == service_model:
|
|
170
|
+
tier = t
|
|
171
|
+
break
|
|
172
|
+
spec = GEMMA_TIERS[tier]
|
|
173
|
+
return cls(
|
|
174
|
+
endpoint=endpoint,
|
|
175
|
+
openai_endpoint=f"{endpoint}/v1",
|
|
176
|
+
tier=tier, # type: ignore[arg-type]
|
|
177
|
+
ollama_model=spec.ollama,
|
|
178
|
+
service_model=spec.service,
|
|
179
|
+
api_key=os.getenv("LOCAL_OPENAI_API_KEY", "local"),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Probe result — typed response returned by the /api/ui/local-model/probe endpoint
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
class LocalModelProbeResult(BaseModel):
|
|
187
|
+
"""Full hardware-detection and Ollama availability result.
|
|
188
|
+
|
|
189
|
+
Returned by ``probe_local_model()`` and serialised as the JSON response from ``GET
|
|
190
|
+
/api/ui/local-model/probe``. The ``to_config()`` helper converts directly into a
|
|
191
|
+
``LocalLLMConfig`` ready for ``run_local_agent()``.
|
|
192
|
+
|
|
193
|
+
Attributes:
|
|
194
|
+
all_tiers (dict[str, str]): The all tiers value.
|
|
195
|
+
available (bool): The available value.
|
|
196
|
+
endpoint (str): The endpoint value.
|
|
197
|
+
label (str): The label value.
|
|
198
|
+
ollama_model (str): The ollama model value.
|
|
199
|
+
openai_endpoint (str): The openai endpoint value.
|
|
200
|
+
pull_command (str): The pull command value.
|
|
201
|
+
pulled_models (list[str]): The pulled models value.
|
|
202
|
+
ram_gb (float): The ram gb value.
|
|
203
|
+
service_model (str): The service model value.
|
|
204
|
+
status_message (str): The status message value.
|
|
205
|
+
tier (Literal["small", "balanced", "large"]): The tier value.
|
|
206
|
+
tier_already_pulled (bool): The tier already pulled value.
|
|
207
|
+
vram_gb (float): The vram gb value.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
available: bool = Field(description="True if Ollama responded to /api/tags")
|
|
211
|
+
endpoint: str
|
|
212
|
+
openai_endpoint: str = Field(description="OpenAI-compatible URL (/v1 suffix)")
|
|
213
|
+
tier: Literal["small", "balanced", "large"]
|
|
214
|
+
ollama_model: str
|
|
215
|
+
service_model: str = Field(
|
|
216
|
+
description="Model string for service.py, e.g. 'local:gemma4:e4b'"
|
|
217
|
+
)
|
|
218
|
+
label: str
|
|
219
|
+
vram_gb: float = Field(ge=0)
|
|
220
|
+
ram_gb: float = Field(ge=0)
|
|
221
|
+
pulled_models: list[str] = Field(default_factory=list)
|
|
222
|
+
tier_already_pulled: bool
|
|
223
|
+
pull_command: str
|
|
224
|
+
status_message: str
|
|
225
|
+
all_tiers: dict[str, str] = Field(
|
|
226
|
+
description="Map of tier name → human label for all supported tiers"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def to_config(self) -> LocalLLMConfig:
|
|
230
|
+
"""Convert probe result into a ready-to-use ``LocalLLMConfig``.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
LocalLLMConfig: The result produced by the operation.
|
|
234
|
+
"""
|
|
235
|
+
return LocalLLMConfig(
|
|
236
|
+
endpoint=self.endpoint,
|
|
237
|
+
openai_endpoint=self.openai_endpoint,
|
|
238
|
+
tier=self.tier,
|
|
239
|
+
ollama_model=self.ollama_model,
|
|
240
|
+
service_model=self.service_model,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class LocalModelPullResult(BaseModel):
|
|
245
|
+
"""Result from ``POST /api/ui/local-model/pull``.
|
|
246
|
+
|
|
247
|
+
Attributes:
|
|
248
|
+
error (str | None): The error value.
|
|
249
|
+
model (str): The model value.
|
|
250
|
+
queued (bool): The queued value.
|
|
251
|
+
response (dict[str, Any] | None): The response value.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
queued: bool
|
|
255
|
+
model: str
|
|
256
|
+
error: str | None = Field(default=None)
|
|
257
|
+
response: dict[str, Any] | None = Field(default=None)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class LocalModelPullRequest(BaseModel):
|
|
261
|
+
"""Request body for ``POST /api/ui/local-model/pull``.
|
|
262
|
+
|
|
263
|
+
Attributes:
|
|
264
|
+
endpoint (str | None): The endpoint value.
|
|
265
|
+
model (str): The model value.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
model: str = Field(description="Ollama model tag to pull, e.g. 'gemma4:e4b'")
|
|
269
|
+
endpoint: str | None = Field(
|
|
270
|
+
default=None,
|
|
271
|
+
description="Override Ollama base URL (omit to use env / default)",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class LocalModelQueryRequest(BaseModel):
|
|
276
|
+
"""Request body for ``POST /api/ui/local-model/query``.
|
|
277
|
+
|
|
278
|
+
Attributes:
|
|
279
|
+
endpoint (str | None): The endpoint value.
|
|
280
|
+
model (str | None): The model value.
|
|
281
|
+
prompt (str): The prompt value.
|
|
282
|
+
system_prompt (str): The system prompt value.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
prompt: str = Field(min_length=1, description="User question or task description")
|
|
286
|
+
system_prompt: str = Field(
|
|
287
|
+
default="You are a SolidWorks CAD design assistant.",
|
|
288
|
+
description="Instruction preamble sent to the LLM",
|
|
289
|
+
)
|
|
290
|
+
model: str | None = Field(
|
|
291
|
+
default=None,
|
|
292
|
+
description="Override model (bare name or 'local:...' prefix). Omit to use env / detected tier.",
|
|
293
|
+
)
|
|
294
|
+
endpoint: str | None = Field(
|
|
295
|
+
default=None,
|
|
296
|
+
description="Override Ollama base URL. Omit to use env / default.",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# Agent result envelope — uniform wrapper for structured LLM outputs
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class LocalAgentResult[T: BaseModel](BaseModel):
|
|
306
|
+
"""Typed envelope wrapping a structured pydantic-ai agent response.
|
|
307
|
+
|
|
308
|
+
``data`` holds the validated ``result_type`` instance; ``config`` echoes back the
|
|
309
|
+
``LocalLLMConfig`` used so callers can log or audit provenance. Set ``success=False``
|
|
310
|
+
and ``error`` when the agent returned a ``RecoverableFailure`` or raised an exception.
|
|
311
|
+
|
|
312
|
+
Attributes:
|
|
313
|
+
config (LocalLLMConfig): The config value.
|
|
314
|
+
data (Any): The data value.
|
|
315
|
+
error (str | None): The error value.
|
|
316
|
+
retry_hint (str | None): The retry hint value.
|
|
317
|
+
success (bool): The success value.
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
success: bool
|
|
321
|
+
data: Any = Field(default=None, description="Validated result_type instance")
|
|
322
|
+
error: str | None = Field(default=None)
|
|
323
|
+
retry_hint: str | None = Field(default=None)
|
|
324
|
+
config: LocalLLMConfig
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
# VRAM / RAM detection
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _detect_gpu_vram_gb() -> float:
|
|
333
|
+
"""Return best-effort GPU VRAM estimate in GB, or 0.0 on failure.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
float: The computed numeric result.
|
|
337
|
+
"""
|
|
338
|
+
# Try nvidia-smi first
|
|
339
|
+
try:
|
|
340
|
+
out = subprocess.check_output(
|
|
341
|
+
["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
|
|
342
|
+
stderr=subprocess.DEVNULL,
|
|
343
|
+
timeout=5,
|
|
344
|
+
text=True,
|
|
345
|
+
)
|
|
346
|
+
mib = max(
|
|
347
|
+
int(x.strip()) for x in out.strip().splitlines() if x.strip().isdigit()
|
|
348
|
+
)
|
|
349
|
+
return mib / 1024.0
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
# Try wmic on Windows
|
|
354
|
+
if platform.system() == "Windows":
|
|
355
|
+
try:
|
|
356
|
+
out = subprocess.check_output(
|
|
357
|
+
["wmic", "path", "win32_VideoController", "get", "AdapterRAM"],
|
|
358
|
+
stderr=subprocess.DEVNULL,
|
|
359
|
+
timeout=5,
|
|
360
|
+
text=True,
|
|
361
|
+
)
|
|
362
|
+
bytes_vals = [
|
|
363
|
+
int(x.strip())
|
|
364
|
+
for x in out.splitlines()
|
|
365
|
+
if x.strip().lstrip("-").isdigit() and int(x.strip()) > 0
|
|
366
|
+
]
|
|
367
|
+
if bytes_vals:
|
|
368
|
+
return max(bytes_vals) / (1024**3)
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
return 0.0
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _detect_system_ram_gb() -> float:
|
|
376
|
+
"""Return total system RAM in GB.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
float: The computed numeric result.
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
import psutil # optional dependency
|
|
383
|
+
|
|
384
|
+
return psutil.virtual_memory().total / (1024**3)
|
|
385
|
+
except ImportError:
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
if platform.system() == "Windows":
|
|
389
|
+
try:
|
|
390
|
+
out = subprocess.check_output(
|
|
391
|
+
["wmic", "computersystem", "get", "TotalPhysicalMemory"],
|
|
392
|
+
stderr=subprocess.DEVNULL,
|
|
393
|
+
timeout=5,
|
|
394
|
+
text=True,
|
|
395
|
+
)
|
|
396
|
+
for line in out.splitlines():
|
|
397
|
+
line = line.strip()
|
|
398
|
+
if line.isdigit():
|
|
399
|
+
return int(line) / (1024**3)
|
|
400
|
+
except Exception:
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
return 0.0
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def recommend_model_tier(vram_gb: float = 0.0, ram_gb: float = 0.0) -> str:
|
|
407
|
+
"""Return 'small' | 'balanced' | 'large' based on available hardware.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
vram_gb (float): The vram gb value. Defaults to 0.0.
|
|
411
|
+
ram_gb (float): The ram gb value. Defaults to 0.0.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
str: The resulting text value.
|
|
415
|
+
"""
|
|
416
|
+
for tier in ("large", "balanced", "small"):
|
|
417
|
+
spec = GEMMA_TIERS[tier]
|
|
418
|
+
if vram_gb >= spec.min_vram_gb and ram_gb >= spec.min_ram_gb:
|
|
419
|
+
return tier
|
|
420
|
+
return "small" # always runnable with quantized 4B
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# ---------------------------------------------------------------------------
|
|
424
|
+
# Ollama probe
|
|
425
|
+
# ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
async def _ollama_health(endpoint: str = OLLAMA_DEFAULT_ENDPOINT) -> bool:
|
|
429
|
+
"""Return True if Ollama HTTP server is responding.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
endpoint (str): The endpoint value. Defaults to OLLAMA_DEFAULT_ENDPOINT.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
bool: True if ollama health, otherwise False.
|
|
436
|
+
"""
|
|
437
|
+
import urllib.request
|
|
438
|
+
|
|
439
|
+
loop = asyncio.get_event_loop()
|
|
440
|
+
try:
|
|
441
|
+
_require_http_endpoint(endpoint)
|
|
442
|
+
|
|
443
|
+
def _get() -> bool:
|
|
444
|
+
"""Build internal get.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
bool: True if get, otherwise False.
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
with urllib.request.urlopen(f"{endpoint}/api/tags", timeout=3) as r: # nosec B310
|
|
452
|
+
return r.status == 200
|
|
453
|
+
except Exception:
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
return await loop.run_in_executor(None, _get)
|
|
457
|
+
except Exception:
|
|
458
|
+
return False
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def _ollama_list_models(endpoint: str = OLLAMA_DEFAULT_ENDPOINT) -> list[str]:
|
|
462
|
+
"""Return list of model names currently pulled in Ollama.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
endpoint (str): The endpoint value. Defaults to OLLAMA_DEFAULT_ENDPOINT.
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
list[str]: A list containing the resulting items.
|
|
469
|
+
"""
|
|
470
|
+
import json
|
|
471
|
+
import urllib.request
|
|
472
|
+
|
|
473
|
+
_require_http_endpoint(endpoint)
|
|
474
|
+
loop = asyncio.get_event_loop()
|
|
475
|
+
|
|
476
|
+
def _get() -> list[str]:
|
|
477
|
+
"""Build internal get.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
list[str]: A list containing the resulting items.
|
|
481
|
+
"""
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
with urllib.request.urlopen(f"{endpoint}/api/tags", timeout=5) as r: # nosec B310
|
|
485
|
+
data = json.loads(r.read())
|
|
486
|
+
return [m.get("name", "") for m in data.get("models", [])]
|
|
487
|
+
except Exception:
|
|
488
|
+
return []
|
|
489
|
+
|
|
490
|
+
return await loop.run_in_executor(None, _get)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
async def probe_local_model(
|
|
494
|
+
endpoint: str | None = None,
|
|
495
|
+
) -> LocalModelProbeResult:
|
|
496
|
+
"""Probe Ollama for availability and return a typed recommendation result.
|
|
497
|
+
|
|
498
|
+
The returned ``LocalModelProbeResult`` can be forwarded directly as a FastAPI JSON
|
|
499
|
+
response (it is a ``BaseModel``). Call ``.to_config()`` on the result to build a
|
|
500
|
+
``LocalLLMConfig`` for ``run_local_agent()``.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
endpoint (str | None): The endpoint value. Defaults to None.
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
LocalModelProbeResult: The result produced by the operation.
|
|
507
|
+
"""
|
|
508
|
+
resolved_endpoint = endpoint or os.getenv(
|
|
509
|
+
"SOLIDWORKS_UI_OLLAMA_ENDPOINT", OLLAMA_DEFAULT_ENDPOINT
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
vram_gb = _detect_gpu_vram_gb()
|
|
513
|
+
ram_gb = _detect_system_ram_gb()
|
|
514
|
+
tier = recommend_model_tier(vram_gb=vram_gb, ram_gb=ram_gb)
|
|
515
|
+
spec = GEMMA_TIERS[tier]
|
|
516
|
+
|
|
517
|
+
available = await _ollama_health(resolved_endpoint)
|
|
518
|
+
pulled_models: list[str] = []
|
|
519
|
+
if available:
|
|
520
|
+
pulled_models = await _ollama_list_models(resolved_endpoint)
|
|
521
|
+
|
|
522
|
+
tier_model = spec.ollama
|
|
523
|
+
tier_already_pulled = any(tier_model in m for m in pulled_models)
|
|
524
|
+
|
|
525
|
+
if not available:
|
|
526
|
+
status = (
|
|
527
|
+
f"Ollama is not running at {resolved_endpoint}. "
|
|
528
|
+
"Install from https://ollama.com and run: ollama serve"
|
|
529
|
+
)
|
|
530
|
+
elif tier_already_pulled:
|
|
531
|
+
status = f"Ready: {spec.label} is loaded in Ollama."
|
|
532
|
+
else:
|
|
533
|
+
status = (
|
|
534
|
+
f"Ollama is running. Pull the recommended model with: "
|
|
535
|
+
f"ollama pull {tier_model}"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
return LocalModelProbeResult(
|
|
539
|
+
available=available,
|
|
540
|
+
endpoint=resolved_endpoint,
|
|
541
|
+
openai_endpoint=f"{resolved_endpoint}/v1",
|
|
542
|
+
tier=tier, # type: ignore[arg-type]
|
|
543
|
+
ollama_model=tier_model,
|
|
544
|
+
service_model=spec.service,
|
|
545
|
+
label=spec.label,
|
|
546
|
+
vram_gb=round(vram_gb, 1),
|
|
547
|
+
ram_gb=round(ram_gb, 1),
|
|
548
|
+
pulled_models=pulled_models,
|
|
549
|
+
tier_already_pulled=tier_already_pulled,
|
|
550
|
+
pull_command=f"ollama pull {tier_model}",
|
|
551
|
+
status_message=status,
|
|
552
|
+
all_tiers={k: v.label for k, v in GEMMA_TIERS.items()},
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
async def pull_ollama_model(
|
|
557
|
+
model: str,
|
|
558
|
+
endpoint: str | None = None,
|
|
559
|
+
) -> LocalModelPullResult:
|
|
560
|
+
"""Trigger an Ollama model pull. Runs in a thread; returns immediately.
|
|
561
|
+
|
|
562
|
+
Returns a typed ``LocalModelPullResult`` with ``queued=True`` on success.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
model (str): The model value.
|
|
566
|
+
endpoint (str | None): The endpoint value. Defaults to None.
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
LocalModelPullResult: The result produced by the operation.
|
|
570
|
+
"""
|
|
571
|
+
import json
|
|
572
|
+
import urllib.request
|
|
573
|
+
|
|
574
|
+
resolved_endpoint = endpoint or os.getenv(
|
|
575
|
+
"SOLIDWORKS_UI_OLLAMA_ENDPOINT", OLLAMA_DEFAULT_ENDPOINT
|
|
576
|
+
)
|
|
577
|
+
_require_http_endpoint(resolved_endpoint)
|
|
578
|
+
loop = asyncio.get_event_loop()
|
|
579
|
+
|
|
580
|
+
def _pull() -> LocalModelPullResult:
|
|
581
|
+
"""Build internal pull.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
LocalModelPullResult: The result produced by the operation.
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
payload = json.dumps({"name": model, "stream": False}).encode()
|
|
588
|
+
req = urllib.request.Request(
|
|
589
|
+
f"{resolved_endpoint}/api/pull",
|
|
590
|
+
data=payload,
|
|
591
|
+
method="POST",
|
|
592
|
+
headers={"Content-Type": "application/json"},
|
|
593
|
+
)
|
|
594
|
+
try:
|
|
595
|
+
with urllib.request.urlopen(req, timeout=300) as r: # nosec B310
|
|
596
|
+
body = json.loads(r.read())
|
|
597
|
+
return LocalModelPullResult(queued=True, model=model, response=body)
|
|
598
|
+
except Exception as exc:
|
|
599
|
+
return LocalModelPullResult(queued=False, model=model, error=str(exc))
|
|
600
|
+
|
|
601
|
+
return await loop.run_in_executor(None, _pull)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# ---------------------------------------------------------------------------
|
|
605
|
+
# pydantic-ai agent runner — structured local LLM inference
|
|
606
|
+
# ---------------------------------------------------------------------------
|
|
607
|
+
async def run_local_agent[T: BaseModel](
|
|
608
|
+
*,
|
|
609
|
+
system_prompt: str,
|
|
610
|
+
user_prompt: str,
|
|
611
|
+
result_type: type[T],
|
|
612
|
+
config: LocalLLMConfig | None = None,
|
|
613
|
+
rag_query: str | None = None,
|
|
614
|
+
rag_namespace: str = "solidworks-api-docs",
|
|
615
|
+
) -> LocalAgentResult[T]:
|
|
616
|
+
"""Run a pydantic-ai ``Agent`` against the local Ollama server and return a.
|
|
617
|
+
|
|
618
|
+
typed ``LocalAgentResult``.
|
|
619
|
+
|
|
620
|
+
This mirrors ``_run_structured_agent`` in ``service.py`` but is self- contained in this
|
|
621
|
+
module so any layer (UI route, service function, or CLI) can call local inference
|
|
622
|
+
without importing the full service graph.
|
|
623
|
+
|
|
624
|
+
Parameters ---------- system_prompt: Instruction preamble for the LLM. user_prompt: The
|
|
625
|
+
concrete question or task. result_type: A ``BaseModel`` subclass. pydantic-ai validates
|
|
626
|
+
the LLM output against this schema and retries automatically on parse failures. config:
|
|
627
|
+
Connection settings. Defaults to ``LocalLLMConfig.from_env()``. rag_query: If provided,
|
|
628
|
+
the FAISS ``solidworks-api-docs`` namespace is queried with this string and the top
|
|
629
|
+
results are prepended to ``system_prompt`` as grounded API context for the model. Pass
|
|
630
|
+
the same text as ``user_prompt`` for a simple "augment with API docs" pattern, or a more
|
|
631
|
+
specific sub-query for targeted retrieval. rag_namespace: FAISS namespace to query when
|
|
632
|
+
``rag_query`` is set. Defaults to ``"solidworks-api-docs"`` (the COM/VBA surface
|
|
633
|
+
index).
|
|
634
|
+
|
|
635
|
+
Returns ------- LocalAgentResult[T] ``success=True`` with ``data`` set to a validated
|
|
636
|
+
``result_type`` instance, or ``success=False`` with an ``error`` message.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
system_prompt (str): The system prompt value.
|
|
640
|
+
user_prompt (str): The user prompt value.
|
|
641
|
+
result_type (type[T]): The result type value.
|
|
642
|
+
config (LocalLLMConfig | None): Configuration values for the operation. Defaults to
|
|
643
|
+
None.
|
|
644
|
+
rag_query (str | None): The rag query value. Defaults to None.
|
|
645
|
+
rag_namespace (str): The rag namespace value. Defaults to "solidworks-api-docs".
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
LocalAgentResult[T]: The result produced by the operation.
|
|
649
|
+
"""
|
|
650
|
+
from ..agents.schemas import RecoverableFailure # avoid circular at import time
|
|
651
|
+
|
|
652
|
+
resolved_config = config or LocalLLMConfig.from_env()
|
|
653
|
+
|
|
654
|
+
# --- RAG augmentation ---
|
|
655
|
+
augmented_system_prompt = system_prompt
|
|
656
|
+
if rag_query:
|
|
657
|
+
try:
|
|
658
|
+
from ..agents.vector_rag import (
|
|
659
|
+
query_design_knowledge,
|
|
660
|
+
query_solidworks_api_docs,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
api_context = (
|
|
664
|
+
query_solidworks_api_docs(rag_query)
|
|
665
|
+
if rag_namespace == "solidworks-api-docs"
|
|
666
|
+
else query_design_knowledge(rag_query, namespace=rag_namespace)
|
|
667
|
+
)
|
|
668
|
+
if api_context:
|
|
669
|
+
augmented_system_prompt = f"{system_prompt}\n\n{api_context}"
|
|
670
|
+
logger.debug(
|
|
671
|
+
"run_local_agent: injected %d RAG chars from '%s'",
|
|
672
|
+
len(api_context),
|
|
673
|
+
rag_namespace,
|
|
674
|
+
)
|
|
675
|
+
except Exception as _rag_exc:
|
|
676
|
+
logger.debug("RAG augmentation skipped: %s", _rag_exc)
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
from pydantic_ai import Agent
|
|
680
|
+
from pydantic_ai.models.openai import OpenAIChatModel
|
|
681
|
+
from pydantic_ai.providers.openai import OpenAIProvider
|
|
682
|
+
except ImportError:
|
|
683
|
+
return LocalAgentResult(
|
|
684
|
+
success=False,
|
|
685
|
+
error="pydantic-ai is not installed in this environment.",
|
|
686
|
+
config=resolved_config,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
model_id = (
|
|
690
|
+
resolved_config.service_model.split(":", 1)[1]
|
|
691
|
+
if resolved_config.service_model.startswith("local:")
|
|
692
|
+
else resolved_config.service_model
|
|
693
|
+
)
|
|
694
|
+
provider = OpenAIProvider(
|
|
695
|
+
base_url=resolved_config.openai_endpoint,
|
|
696
|
+
api_key=resolved_config.api_key,
|
|
697
|
+
)
|
|
698
|
+
configured_model = OpenAIChatModel(model_id, provider=provider)
|
|
699
|
+
|
|
700
|
+
agent: Agent[None, T | RecoverableFailure] = Agent(
|
|
701
|
+
configured_model,
|
|
702
|
+
system_prompt=augmented_system_prompt,
|
|
703
|
+
output_type=[result_type, RecoverableFailure], # type: ignore[list-item]
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
try:
|
|
707
|
+
result = await agent.run(user_prompt)
|
|
708
|
+
payload = result.data if hasattr(result, "data") else result.output
|
|
709
|
+
except Exception as exc:
|
|
710
|
+
logger.exception("run_local_agent failed")
|
|
711
|
+
return LocalAgentResult(
|
|
712
|
+
success=False,
|
|
713
|
+
error=str(exc),
|
|
714
|
+
config=resolved_config,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
if isinstance(payload, RecoverableFailure):
|
|
718
|
+
return LocalAgentResult(
|
|
719
|
+
success=False,
|
|
720
|
+
error=payload.explanation,
|
|
721
|
+
retry_hint=getattr(payload, "retry_hint", None),
|
|
722
|
+
config=resolved_config,
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
return LocalAgentResult(success=True, data=payload, config=resolved_config)
|