promptmc 0.3.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.
Files changed (46) hide show
  1. promptmc/__init__.py +22 -0
  2. promptmc/_typing.py +5 -0
  3. promptmc/assistant.py +460 -0
  4. promptmc/batch.py +478 -0
  5. promptmc/benchmarks/__init__.py +13 -0
  6. promptmc/benchmarks/_types.py +21 -0
  7. promptmc/benchmarks/godiva.py +52 -0
  8. promptmc/benchmarks/pwr_pin.py +117 -0
  9. promptmc/cli.py +97 -0
  10. promptmc/commands/__init__.py +3 -0
  11. promptmc/commands/analyze.py +39 -0
  12. promptmc/commands/batch.py +77 -0
  13. promptmc/commands/common.py +36 -0
  14. promptmc/commands/configure.py +68 -0
  15. promptmc/commands/info.py +88 -0
  16. promptmc/commands/plan.py +83 -0
  17. promptmc/commands/run.py +103 -0
  18. promptmc/commands/templates.py +81 -0
  19. promptmc/commands/validate.py +84 -0
  20. promptmc/errors.py +234 -0
  21. promptmc/examples/uo2_criticality/README.md +46 -0
  22. promptmc/examples/uo2_criticality/geometry.xml +7 -0
  23. promptmc/examples/uo2_criticality/materials.xml +14 -0
  24. promptmc/examples/uo2_criticality/settings.xml +12 -0
  25. promptmc/geometry/__init__.py +53 -0
  26. promptmc/geometry/materials.py +52 -0
  27. promptmc/geometry/primitives.py +216 -0
  28. promptmc/geometry/tallies.py +50 -0
  29. promptmc/geometry/xml_serializer.py +312 -0
  30. promptmc/mcp/__init__.py +11 -0
  31. promptmc/mcp/resources.py +86 -0
  32. promptmc/mcp/schemas.py +225 -0
  33. promptmc/mcp/server.py +113 -0
  34. promptmc/mcp/tools.py +778 -0
  35. promptmc/openmc_integration.py +290 -0
  36. promptmc/progress.py +587 -0
  37. promptmc/resources.py +270 -0
  38. promptmc/schema.py +431 -0
  39. promptmc/telemetry.py +337 -0
  40. promptmc/templates.py +376 -0
  41. promptmc/visualization.py +355 -0
  42. promptmc-0.3.0.dist-info/METADATA +154 -0
  43. promptmc-0.3.0.dist-info/RECORD +46 -0
  44. promptmc-0.3.0.dist-info/WHEEL +4 -0
  45. promptmc-0.3.0.dist-info/entry_points.txt +4 -0
  46. promptmc-0.3.0.dist-info/licenses/LICENSE +21 -0
promptmc/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """PromptMC: AI Assistant and CLI for OpenMC workflows."""
2
+
3
+ from promptmc.batch import BatchRunner, ParallelConfig, ParallelMode
4
+ from promptmc.openmc_integration import (
5
+ ExecutionMode,
6
+ OpenMCInstaller,
7
+ OpenMCRunner,
8
+ OpenMCValidator,
9
+ )
10
+
11
+ __version__ = "0.3.0"
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "BatchRunner",
16
+ "ExecutionMode",
17
+ "OpenMCInstaller",
18
+ "OpenMCRunner",
19
+ "OpenMCValidator",
20
+ "ParallelConfig",
21
+ "ParallelMode",
22
+ ]
promptmc/_typing.py ADDED
@@ -0,0 +1,5 @@
1
+ """Common type aliases for PromptMC."""
2
+
3
+ from pathlib import Path
4
+
5
+ PathLike = str | Path
promptmc/assistant.py ADDED
@@ -0,0 +1,460 @@
1
+ """Natural-language assistant for OpenMC configuration planning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any, TypeVar
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ from promptmc.templates import TemplateType, get_template
16
+
17
+ DEFAULT_GEMINI_MODEL = "gemini-3.5-flash"
18
+
19
+ SUPPORTED_TEMPLATE_TYPES = {
20
+ TemplateType.CRITICALITY,
21
+ TemplateType.FIXED_SOURCE,
22
+ TemplateType.SHIELDING,
23
+ TemplateType.REACTOR_PIN,
24
+ }
25
+
26
+
27
+ @dataclass
28
+ class NaturalLanguagePlan:
29
+ """A simulation plan derived from natural language input."""
30
+
31
+ prompt: str
32
+ template_type: TemplateType
33
+ particles: int
34
+ batches: int
35
+ inactive: int
36
+ confidence: float
37
+ summary: str
38
+ rationale: list[str] = field(default_factory=list)
39
+ warnings: list[str] = field(default_factory=list)
40
+ next_steps: list[str] = field(default_factory=list)
41
+ source: str = "local"
42
+
43
+ def command(self, output_path: str | Path = "settings.xml") -> str:
44
+ """Generate the CLI command to execute this plan."""
45
+ parts = [
46
+ "promptmc",
47
+ "template",
48
+ self.template_type.value,
49
+ "--output",
50
+ str(output_path),
51
+ "--particles",
52
+ str(self.particles),
53
+ "--batches",
54
+ str(self.batches),
55
+ ]
56
+ if self.inactive:
57
+ parts.extend(["--inactive", str(self.inactive)])
58
+ return shlex.join(parts)
59
+
60
+ def render(self, output_path: str | Path) -> Path:
61
+ """Render the plan to an XML settings file."""
62
+ template = get_template(self.template_type)
63
+ return template.render(
64
+ output_path=output_path,
65
+ particles=self.particles,
66
+ batches=self.batches,
67
+ inactive=self.inactive,
68
+ )
69
+
70
+
71
+ T = TypeVar("T", bound=BaseModel)
72
+
73
+
74
+ class GeminiPlanResponse(BaseModel):
75
+ """Pydantic schema for the Gemini structured output plan."""
76
+
77
+ template_type: str = Field(
78
+ description="One of 'criticality', 'fixed_source', 'shielding', or 'reactor_pin'"
79
+ )
80
+ particles: int = Field(
81
+ description="Number of particles to simulate, must be a positive integer"
82
+ )
83
+ batches: int = Field(
84
+ description="Number of batches to simulate, must be a positive integer"
85
+ )
86
+ inactive: int = Field(
87
+ description="Number of inactive batches to simulate (must be 0 for fixed_source and shielding)"
88
+ )
89
+ confidence: float = Field(
90
+ description="Confidence score between 0.0 and 1.0"
91
+ )
92
+ summary: str = Field(description="A short summary of the plan")
93
+ rationale: list[str] = Field(
94
+ default_factory=list, description="Reasoning behind the chosen plan"
95
+ )
96
+ warnings: list[str] = Field(
97
+ default_factory=list,
98
+ description="Any warnings or potential issues detected",
99
+ )
100
+ next_steps: list[str] = Field(
101
+ default_factory=list, description="Next steps for the user to follow"
102
+ )
103
+
104
+
105
+ class GeminiClient:
106
+ """A thin client wrapper for Google Gemini API."""
107
+
108
+ def __init__(
109
+ self,
110
+ api_key: str | None = None,
111
+ model: str | None = None,
112
+ ) -> None:
113
+ self.api_key: str | None = api_key or os.getenv("GEMINI_API_KEY")
114
+ self.model: str = (
115
+ model or os.getenv("GEMINI_MODEL") or DEFAULT_GEMINI_MODEL
116
+ )
117
+
118
+ @property
119
+ def configured(self) -> bool:
120
+ """Whether the client has an API key configured."""
121
+ return bool(self.api_key)
122
+
123
+ def generate_structured(
124
+ self,
125
+ system_prompt: str,
126
+ user_prompt: str,
127
+ response_schema: type[T],
128
+ ) -> T:
129
+ """Call Gemini to generate structured output conforming to the response_schema."""
130
+ if not self.api_key:
131
+ raise ValueError(
132
+ "GEMINI_API_KEY environment variable is not set. "
133
+ "Set this variable to use the Gemini LLM planner, "
134
+ "or omit the --llm flag to use the default local planner."
135
+ )
136
+
137
+ # Lazy import: import google-genai ONLY inside the seam that makes the call
138
+ from google import genai
139
+ from google.genai import types
140
+
141
+ client = genai.Client(api_key=self.api_key)
142
+
143
+ try:
144
+ response = client.models.generate_content(
145
+ model=self.model,
146
+ contents=user_prompt,
147
+ config=types.GenerateContentConfig(
148
+ system_instruction=system_prompt,
149
+ response_mime_type="application/json",
150
+ response_schema=response_schema,
151
+ temperature=0.1,
152
+ ),
153
+ )
154
+ except Exception as e:
155
+ raise RuntimeError(f"Gemini API request failed: {e}") from e
156
+
157
+ if not response.text:
158
+ raise RuntimeError("Gemini returned an empty response")
159
+
160
+ try:
161
+ return response_schema.model_validate_json(response.text)
162
+ except Exception as e:
163
+ raise RuntimeError(
164
+ f"Failed to parse Gemini response as {response_schema.__name__}: {e}. "
165
+ f"Raw response: {response.text}"
166
+ ) from e
167
+
168
+
169
+ class NaturalLanguageAssistant:
170
+ """Translates natural language into OpenMC simulation plans."""
171
+
172
+ def __init__(self, llm_client: GeminiClient | None = None) -> None:
173
+ self.llm_client = llm_client or GeminiClient()
174
+
175
+ def plan(
176
+ self,
177
+ prompt: str,
178
+ use_llm: bool = False,
179
+ model: str | None = None,
180
+ ) -> NaturalLanguagePlan:
181
+ """Generate a simulation plan from a prompt."""
182
+ local_plan = self._local_plan(prompt)
183
+ if not use_llm:
184
+ return local_plan
185
+
186
+ client = self.llm_client
187
+ if model:
188
+ client = GeminiClient(model=model)
189
+
190
+ if not client.configured:
191
+ raise ValueError(
192
+ "GEMINI_API_KEY environment variable is not set. "
193
+ "Set this variable to use the Gemini LLM planner, "
194
+ "or omit the --llm flag to use the default local planner."
195
+ )
196
+
197
+ return self._llm_plan(prompt, local_plan, client)
198
+
199
+ def _local_plan(self, prompt: str) -> NaturalLanguagePlan:
200
+ normalized = prompt.lower()
201
+ template_type, confidence, rationale = self._infer_template(normalized)
202
+ template = get_template(template_type)
203
+ particles = self._extract_labeled_count(
204
+ normalized,
205
+ (
206
+ "particles",
207
+ "particle",
208
+ "histories",
209
+ "history",
210
+ "neutrons",
211
+ "photons",
212
+ ),
213
+ template.metadata.default_particles,
214
+ )
215
+ batches = self._extract_labeled_count(
216
+ normalized,
217
+ ("batches", "batch", "cycles", "cycle"),
218
+ template.metadata.default_batches,
219
+ )
220
+ inactive = self._extract_labeled_count(
221
+ normalized,
222
+ ("inactive", "skip", "skipped"),
223
+ template.metadata.default_inactive,
224
+ )
225
+
226
+ warnings: list[str] = []
227
+ if "depletion" in normalized or "burnup" in normalized:
228
+ warnings.append(
229
+ "Depletion was detected, but the built-in"
230
+ " depletion template is not implemented"
231
+ " yet. Use the generated plan as a"
232
+ " starting point and add depletion"
233
+ " settings manually."
234
+ )
235
+ if (
236
+ template_type in {TemplateType.FIXED_SOURCE, TemplateType.SHIELDING}
237
+ and inactive
238
+ ):
239
+ inactive = 0
240
+ warnings.append(
241
+ "Inactive batches are not used for"
242
+ " fixed-source-style calculations."
243
+ )
244
+
245
+ next_steps = [
246
+ "Generate settings.xml from the recommended template.",
247
+ "Add or verify materials.xml and geometry.xml for the physical model.",
248
+ "Run schema validation before launching OpenMC.",
249
+ ]
250
+ summary = self._summary(template_type, particles, batches, inactive)
251
+
252
+ return NaturalLanguagePlan(
253
+ prompt=prompt,
254
+ template_type=template_type,
255
+ particles=particles,
256
+ batches=batches,
257
+ inactive=inactive,
258
+ confidence=confidence,
259
+ summary=summary,
260
+ rationale=rationale,
261
+ warnings=warnings,
262
+ next_steps=next_steps,
263
+ source="local",
264
+ )
265
+
266
+ def _llm_plan(
267
+ self,
268
+ prompt: str,
269
+ fallback: NaturalLanguagePlan,
270
+ client: GeminiClient,
271
+ ) -> NaturalLanguagePlan:
272
+ system_prompt = (
273
+ "You translate OpenMC simulation requests into a simulation plan JSON object. "
274
+ "Valid template_type values are 'criticality', 'fixed_source', 'shielding', and 'reactor_pin'. "
275
+ "Use positive integers for particles and batches. "
276
+ "Use inactive=0 for fixed_source and shielding."
277
+ )
278
+ user_prompt = json.dumps(
279
+ {
280
+ "request": prompt,
281
+ "fallback_plan": {
282
+ "template_type": fallback.template_type.value,
283
+ "particles": fallback.particles,
284
+ "batches": fallback.batches,
285
+ "inactive": fallback.inactive,
286
+ },
287
+ }
288
+ )
289
+
290
+ plan_response = client.generate_structured(
291
+ system_prompt, user_prompt, GeminiPlanResponse
292
+ )
293
+
294
+ try:
295
+ template_type = TemplateType(plan_response.template_type)
296
+ except ValueError:
297
+ template_type = fallback.template_type
298
+
299
+ if template_type not in SUPPORTED_TEMPLATE_TYPES:
300
+ template_type = fallback.template_type
301
+
302
+ return NaturalLanguagePlan(
303
+ prompt=prompt,
304
+ template_type=template_type,
305
+ particles=max(1, plan_response.particles),
306
+ batches=max(1, plan_response.batches),
307
+ inactive=max(0, plan_response.inactive),
308
+ confidence=max(0.0, min(1.0, plan_response.confidence)),
309
+ summary=plan_response.summary,
310
+ rationale=plan_response.rationale,
311
+ warnings=plan_response.warnings,
312
+ next_steps=plan_response.next_steps,
313
+ source="llm",
314
+ )
315
+
316
+ KEYWORD_TEMPLATES: dict[TemplateType, tuple[list[re.Pattern[str]], str]] = {
317
+ TemplateType.SHIELDING: (
318
+ [
319
+ re.compile(rf"\b{kw}\b", re.IGNORECASE)
320
+ for kw in (
321
+ "shield",
322
+ "shielding",
323
+ "dose",
324
+ "attenuation",
325
+ "concrete",
326
+ "lead",
327
+ "barrier",
328
+ )
329
+ ],
330
+ "Shielding/dose keywords suggest a shielding calculation.",
331
+ ),
332
+ TemplateType.REACTOR_PIN: (
333
+ [
334
+ re.compile(rf"\b{kw}\b", re.IGNORECASE)
335
+ for kw in (
336
+ "pin",
337
+ "pin-cell",
338
+ "pincell",
339
+ "fuel rod",
340
+ "fuel pellet",
341
+ "cladding",
342
+ )
343
+ ],
344
+ "Pin-cell keywords suggest the reactor pin template.",
345
+ ),
346
+ TemplateType.FIXED_SOURCE: (
347
+ [
348
+ re.compile(rf"\b{kw}\b", re.IGNORECASE)
349
+ for kw in (
350
+ "fixed source",
351
+ "source",
352
+ "beam",
353
+ "dosimetry",
354
+ "14 mev",
355
+ "photon",
356
+ "gamma",
357
+ )
358
+ ],
359
+ "Source/dosimetry keywords suggest a fixed-source calculation.",
360
+ ),
361
+ TemplateType.CRITICALITY: (
362
+ [
363
+ re.compile(rf"\b{kw}\b", re.IGNORECASE)
364
+ for kw in (
365
+ "criticality",
366
+ "keff",
367
+ "k-effective",
368
+ "eigenvalue",
369
+ "reactor",
370
+ "multiplication",
371
+ )
372
+ ],
373
+ "Criticality/eigenvalue keywords suggest a criticality calculation.",
374
+ ),
375
+ }
376
+
377
+ def _infer_template(
378
+ self, normalized: str
379
+ ) -> tuple[TemplateType, float, list[str]]:
380
+ matches: list[tuple[int, TemplateType, str]] = []
381
+ for template_type, (patterns, reason) in self.KEYWORD_TEMPLATES.items():
382
+ score = sum(1 for p in patterns if p.search(normalized))
383
+ if score:
384
+ matches.append((score, template_type, reason))
385
+
386
+ if not matches:
387
+ return (
388
+ TemplateType.CRITICALITY,
389
+ 0.45,
390
+ [
391
+ "No strong domain keywords were found, so criticality was chosen as a safe default."
392
+ ],
393
+ )
394
+
395
+ score, template_type, reason = sorted(
396
+ matches, key=lambda item: item[0], reverse=True
397
+ )[0]
398
+ confidence = min(0.95, 0.55 + 0.1 * score)
399
+ return template_type, confidence, [reason]
400
+
401
+ def _extract_labeled_count(
402
+ self,
403
+ normalized: str,
404
+ labels: tuple[str, ...],
405
+ default: int,
406
+ ) -> int:
407
+ label_group = "|".join(re.escape(label) for label in labels)
408
+ after_pattern = re.compile( # noqa: E501
409
+ rf"(?:{label_group})\D{{0,24}}(?P<number>\d[\d,]*(?:\.\d+)?(?:e[+-]?\d+)?)(?P<suffix>\s*(?:k|m|thousand|million))?",
410
+ re.IGNORECASE,
411
+ )
412
+ before_pattern = re.compile( # noqa: E501
413
+ rf"(?P<number>\d[\d,]*(?:\.\d+)?(?:e[+-]?\d+)?)(?P<suffix>\s*(?:k|m|thousand|million))?\s+(?:{label_group})",
414
+ re.IGNORECASE,
415
+ )
416
+ for pattern in (before_pattern, after_pattern):
417
+ match = pattern.search(normalized)
418
+ if match:
419
+ return max(
420
+ 1,
421
+ self._parse_number(
422
+ match.group("number"), match.group("suffix") or ""
423
+ ),
424
+ )
425
+ return default
426
+
427
+ @staticmethod
428
+ def _parse_number(number: str, suffix: str) -> int:
429
+ value = float(number.replace(",", ""))
430
+ suffix = suffix.strip().lower()
431
+ if suffix in {"k", "thousand"}:
432
+ value *= 1_000
433
+ elif suffix in {"m", "million"}:
434
+ value *= 1_000_000
435
+ return int(value)
436
+
437
+ @staticmethod
438
+ def _summary(
439
+ template_type: TemplateType,
440
+ particles: int,
441
+ batches: int,
442
+ inactive: int,
443
+ ) -> str:
444
+ if inactive:
445
+ return (
446
+ f"Use the {template_type.value} template with {particles:,} "
447
+ f"particles, {batches} batches, and {inactive} inactive batches."
448
+ )
449
+ return (
450
+ f"Use the {template_type.value} template with {particles:,} "
451
+ f"particles and {batches} batches."
452
+ )
453
+
454
+ @staticmethod
455
+ def _string_list(value: Any) -> list[str]:
456
+ if isinstance(value, list):
457
+ return [str(item) for item in value]
458
+ if value is None:
459
+ return []
460
+ return [str(value)]