grimoire-spec 0.1.1__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.
grimoire/__init__.py ADDED
File without changes
grimoire/loader.py ADDED
@@ -0,0 +1,444 @@
1
+ """SystemLoader — loads a GRIMOIRE system directory into a System object."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from grimoire.models.compendium_definition import CompendiumDefinition
9
+ from grimoire.models.flow import (
10
+ ChoiceDefinition,
11
+ DiceSequenceDefinition,
12
+ FlowDefinition,
13
+ LLMSettingsDefinition,
14
+ LLMValidationDefinition,
15
+ StepDefinition,
16
+ StepType,
17
+ TableRollDefinition,
18
+ VariableDefinition,
19
+ )
20
+ from grimoire.models.model_definition import ModelDefinition, ValidationRule
21
+ from grimoire.models.prompt import PromptDefinition
22
+ from grimoire.models.source import SourceDefinition
23
+ from grimoire.models.system import Credits, Currency, CurrencyDenomination, System
24
+ from grimoire.models.table import TableDefinition
25
+
26
+
27
+ class SystemLoadError(Exception):
28
+ """Raised when a GRIMOIRE system directory cannot be loaded."""
29
+
30
+
31
+ class SystemLoader:
32
+ """Loads a GRIMOIRE system directory into a :class:`System` instance.
33
+
34
+ Usage::
35
+
36
+ loader = SystemLoader()
37
+ system = loader.load(Path("systems/knave_1e"))
38
+ errors = system.validate()
39
+ """
40
+
41
+ def load(self, system_path: Path) -> System:
42
+ """Load a complete system from a directory.
43
+
44
+ Args:
45
+ system_path: Path to the system directory (must contain system.yaml).
46
+
47
+ Returns:
48
+ A fully populated :class:`System` instance.
49
+
50
+ Raises:
51
+ SystemLoadError: If the path does not exist, system.yaml is missing,
52
+ or any YAML file is malformed.
53
+ """
54
+ if not system_path.exists():
55
+ raise SystemLoadError(f"System path does not exist: {system_path}")
56
+
57
+ system_yaml = system_path / "system.yaml"
58
+ if not system_yaml.exists():
59
+ raise SystemLoadError(f"Missing system.yaml in {system_path}")
60
+
61
+ data = self._read_yaml(system_yaml)
62
+ system = self._parse_system(data)
63
+ system._system_path = system_path
64
+
65
+ self._load_models(system, system_path / "models")
66
+ self._load_tables(system, system_path / "tables")
67
+ self._load_compendiums(system, system_path / "compendiums")
68
+ self._load_flows(system, system_path / "flows")
69
+ self._load_prompts(system, system_path / "prompts")
70
+ self._load_sources(system, system_path / "sources")
71
+
72
+ return system
73
+
74
+ # ------------------------------------------------------------------
75
+ # YAML helpers
76
+ # ------------------------------------------------------------------
77
+
78
+ def _read_yaml(self, path: Path) -> dict[str, Any]:
79
+ try:
80
+ with path.open(encoding="utf-8") as f:
81
+ return yaml.safe_load(f) or {}
82
+ except yaml.YAMLError as exc:
83
+ raise SystemLoadError(f"Invalid YAML in {path}: {exc}") from exc
84
+
85
+ # ------------------------------------------------------------------
86
+ # System
87
+ # ------------------------------------------------------------------
88
+
89
+ def _parse_system(self, data: dict[str, Any]) -> System:
90
+ currency = None
91
+ if "currency" in data:
92
+ currency = self._parse_currency(data["currency"])
93
+
94
+ credits_obj = None
95
+ if "credits" in data:
96
+ credits_obj = self._parse_credits(data["credits"])
97
+
98
+ return System(
99
+ id=data["id"],
100
+ name=data["name"],
101
+ kind=data.get("kind", "system"),
102
+ description=data.get("description"),
103
+ version=str(data["version"]) if "version" in data else None,
104
+ default_source=data.get("default_source"),
105
+ currency=currency,
106
+ credits=credits_obj,
107
+ )
108
+
109
+ def _parse_currency(self, data: dict[str, Any]) -> Currency:
110
+ denominations: dict[str, CurrencyDenomination] = {}
111
+ for denom_id, denom_data in data.get("denominations", {}).items():
112
+ denominations[denom_id] = CurrencyDenomination(
113
+ name=denom_data["name"],
114
+ symbol=denom_data["symbol"],
115
+ value=denom_data["value"],
116
+ weight=denom_data.get("weight"),
117
+ )
118
+ return Currency(base_unit=data["base_unit"], denominations=denominations)
119
+
120
+ def _parse_credits(self, data: dict[str, Any]) -> Credits:
121
+ return Credits(
122
+ author=data.get("author"),
123
+ license=data.get("license"),
124
+ publisher=data.get("publisher"),
125
+ source_url=data.get("source_url"),
126
+ )
127
+
128
+ # ------------------------------------------------------------------
129
+ # Models
130
+ # ------------------------------------------------------------------
131
+
132
+ def _load_models(self, system: System, models_dir: Path) -> None:
133
+ if not models_dir.exists():
134
+ return
135
+ for yaml_file in sorted(models_dir.glob("*.yaml")):
136
+ try:
137
+ data = self._read_yaml(yaml_file)
138
+ model = self._parse_model(data)
139
+ system.models[model.id] = model
140
+ except SystemLoadError:
141
+ raise
142
+ except Exception as exc:
143
+ raise SystemLoadError(
144
+ f"Failed to load model {yaml_file}: {exc}"
145
+ ) from exc
146
+
147
+ def _parse_model(self, data: dict[str, Any]) -> ModelDefinition:
148
+ validations = [
149
+ ValidationRule(
150
+ expression=v["expression"],
151
+ message=v.get("message", "Validation failed"),
152
+ )
153
+ for v in data.get("validations", [])
154
+ ]
155
+ return ModelDefinition(
156
+ id=data["id"],
157
+ name=data["name"],
158
+ kind=data.get("kind", "model"),
159
+ description=data.get("description"),
160
+ version=int(data.get("version", 1)),
161
+ extends=data.get("extends", []),
162
+ attributes=data.get("attributes", {}),
163
+ validations=validations,
164
+ )
165
+
166
+ # ------------------------------------------------------------------
167
+ # Tables
168
+ # ------------------------------------------------------------------
169
+
170
+ def _load_tables(self, system: System, tables_dir: Path) -> None:
171
+ if not tables_dir.exists():
172
+ return
173
+ for yaml_file in sorted(tables_dir.rglob("*.yaml")):
174
+ try:
175
+ data = self._read_yaml(yaml_file)
176
+ table = self._parse_table(data)
177
+ if table.id:
178
+ system.tables[table.id] = table
179
+ except SystemLoadError:
180
+ raise
181
+ except Exception as exc:
182
+ raise SystemLoadError(
183
+ f"Failed to load table {yaml_file}: {exc}"
184
+ ) from exc
185
+
186
+ def _parse_table(self, data: dict[str, Any]) -> TableDefinition:
187
+ # Preserve original entry keys as strings so range-style keys like
188
+ # "1-3" remain intact; YAML may parse pure-integer keys as int.
189
+ entries: dict[int | str, Any] = {
190
+ k: v for k, v in data.get("entries", {}).items()
191
+ }
192
+ return TableDefinition(
193
+ kind=data.get("kind", "table"),
194
+ name=data["name"],
195
+ id=data.get("id"),
196
+ display_name=data.get("display_name"),
197
+ version=str(data.get("version", "1.0")),
198
+ roll=str(data["roll"]) if "roll" in data else None,
199
+ description=data.get("description"),
200
+ entry_type=data.get("entry_type", "str"),
201
+ entries=entries,
202
+ )
203
+
204
+ # ------------------------------------------------------------------
205
+ # Compendiums
206
+ # ------------------------------------------------------------------
207
+
208
+ def _load_compendiums(self, system: System, compendiums_dir: Path) -> None:
209
+ if not compendiums_dir.exists():
210
+ return
211
+ for yaml_file in sorted(compendiums_dir.rglob("*.yaml")):
212
+ try:
213
+ data = self._read_yaml(yaml_file)
214
+ compendium = self._parse_compendium(data)
215
+ system.compendiums[compendium.id] = compendium
216
+ except SystemLoadError:
217
+ raise
218
+ except Exception as exc:
219
+ raise SystemLoadError(
220
+ f"Failed to load compendium {yaml_file}: {exc}"
221
+ ) from exc
222
+
223
+ def _parse_compendium(self, data: dict[str, Any]) -> CompendiumDefinition:
224
+ entries_raw = data.get("entries", {})
225
+ # Handle list format (e.g. wyrdbound): list of dicts each containing
226
+ # an 'id' field — convert to the canonical dict-keyed format.
227
+ if isinstance(entries_raw, list):
228
+ entries: dict[str, dict[str, Any]] = {}
229
+ for entry in entries_raw:
230
+ entry_copy = dict(entry)
231
+ entry_id = str(entry_copy.pop("id"))
232
+ entries[entry_id] = entry_copy
233
+ else:
234
+ entries = {str(k): v for k, v in entries_raw.items()}
235
+
236
+ return CompendiumDefinition(
237
+ kind=data.get("kind", "compendium"),
238
+ id=data["id"],
239
+ name=data["name"],
240
+ model=data["model"],
241
+ entries=entries,
242
+ )
243
+
244
+ # ------------------------------------------------------------------
245
+ # Flows
246
+ # ------------------------------------------------------------------
247
+
248
+ def _load_flows(self, system: System, flows_dir: Path) -> None:
249
+ if not flows_dir.exists():
250
+ return
251
+ for yaml_file in sorted(flows_dir.rglob("*.yaml")):
252
+ try:
253
+ data = self._read_yaml(yaml_file)
254
+ flow = self._parse_flow(data)
255
+ system.flows[flow.id] = flow
256
+ except SystemLoadError:
257
+ raise
258
+ except Exception as exc:
259
+ raise SystemLoadError(
260
+ f"Failed to load flow {yaml_file}: {exc}"
261
+ ) from exc
262
+
263
+ def _parse_flow(self, data: dict[str, Any]) -> FlowDefinition:
264
+ version_raw = data.get("version")
265
+ return FlowDefinition(
266
+ id=data["id"],
267
+ name=data["name"],
268
+ # YAML uses 'kind', but FlowDefinition uses 'type'
269
+ type=data.get("kind", "flow"),
270
+ description=data.get("description"),
271
+ version=str(version_raw) if version_raw is not None else None,
272
+ inputs=[self._parse_variable(v) for v in data.get("inputs", [])],
273
+ outputs=[self._parse_variable(v) for v in data.get("outputs", [])],
274
+ variables=[self._parse_variable(v) for v in data.get("variables", [])],
275
+ steps=[self._parse_step(s) for s in data.get("steps", [])],
276
+ resume_points=data.get("resume_points", []),
277
+ )
278
+
279
+ def _parse_variable(self, data: dict[str, Any]) -> VariableDefinition:
280
+ return VariableDefinition(
281
+ id=data["id"],
282
+ type=data.get("type", "unknown"),
283
+ description=data.get("description"),
284
+ default=data.get("default"),
285
+ enum=data.get("enum"),
286
+ )
287
+
288
+ def _parse_step(self, data: dict[str, Any]) -> StepDefinition:
289
+ type_str = data.get("type", "")
290
+ try:
291
+ step_type: StepType | None = StepType(type_str)
292
+ except ValueError:
293
+ step_type = None
294
+
295
+ choices = [self._parse_choice(c) for c in data.get("choices", [])]
296
+
297
+ tables = [
298
+ TableRollDefinition(
299
+ table=t["table"],
300
+ count=t.get("count", 1),
301
+ actions=t.get("actions", []),
302
+ )
303
+ for t in data.get("tables", [])
304
+ ]
305
+
306
+ sequence: DiceSequenceDefinition | None = None
307
+ if "sequence" in data:
308
+ sd = data["sequence"]
309
+ sequence = DiceSequenceDefinition(
310
+ items=sd["items"],
311
+ roll=sd["roll"],
312
+ actions=sd.get("actions"),
313
+ )
314
+
315
+ llm_settings: LLMSettingsDefinition | None = None
316
+ if "llm_settings" in data:
317
+ ls = data["llm_settings"]
318
+ llm_settings = LLMSettingsDefinition(
319
+ provider=ls.get("provider", "anthropic"),
320
+ model=ls.get("model", "claude-3-haiku"),
321
+ max_tokens=ls.get("max_tokens", 200),
322
+ temperature=ls.get("temperature", 0.7),
323
+ )
324
+
325
+ validation: LLMValidationDefinition | None = None
326
+ if "validation" in data:
327
+ vd = data["validation"]
328
+ validation = LLMValidationDefinition(
329
+ type=vd["type"],
330
+ schema=vd.get("schema"),
331
+ max_attempts=vd.get("max_attempts", 3),
332
+ cleanup_enabled=vd.get("cleanup_enabled", True),
333
+ on_failure=vd.get("on_failure", "continue"),
334
+ fallback_value=vd.get("fallback_value"),
335
+ )
336
+
337
+ return StepDefinition(
338
+ id=data["id"],
339
+ name=data.get("name"),
340
+ type=step_type,
341
+ actions=data.get("actions", []),
342
+ next_step=data.get("next_step"),
343
+ prompt=data.get("prompt"),
344
+ condition=data.get("condition"),
345
+ result_message=data.get("result_message"),
346
+ output=data.get("output"),
347
+ roll=data.get("roll"),
348
+ sequence=sequence,
349
+ choices=choices,
350
+ choice_source=data.get("choice_source"),
351
+ pre_actions=data.get("pre_actions", []),
352
+ post_actions=data.get("post_actions", []),
353
+ tables=tables,
354
+ prompt_id=data.get("prompt_id"),
355
+ prompt_data=data.get("prompt_data") or {},
356
+ llm_settings=llm_settings,
357
+ validation=validation,
358
+ # conditional_branch: YAML uses 'if'/'then'/'else'
359
+ if_condition=data.get("if"),
360
+ then_actions=data.get("then") or [],
361
+ else_actions=data.get("else"),
362
+ flow=data.get("flow"),
363
+ inputs=data.get("inputs") or {},
364
+ result=data.get("result"),
365
+ generator=data.get("generator"),
366
+ settings=data.get("settings") or {},
367
+ )
368
+
369
+ def _parse_choice(self, data: dict[str, Any]) -> ChoiceDefinition:
370
+ # Some YAML files use 'value' instead of 'id' for the choice identifier
371
+ choice_id = data.get("id") or data.get("value", "")
372
+ return ChoiceDefinition(
373
+ id=str(choice_id),
374
+ label=data.get("label", ""),
375
+ next_step=data.get("next_step"),
376
+ actions=data.get("actions", []),
377
+ reset_outputs=data.get("reset_outputs", False),
378
+ description=data.get("description"),
379
+ )
380
+
381
+ # ------------------------------------------------------------------
382
+ # Prompts
383
+ # ------------------------------------------------------------------
384
+
385
+ def _load_prompts(self, system: System, prompts_dir: Path) -> None:
386
+ if not prompts_dir.exists():
387
+ return
388
+ for yaml_file in sorted(prompts_dir.glob("*.yaml")):
389
+ try:
390
+ data = self._read_yaml(yaml_file)
391
+ prompt = self._parse_prompt(data)
392
+ prompt_id = prompt.id or prompt.name
393
+ system.prompts[prompt_id] = prompt
394
+ except SystemLoadError:
395
+ raise
396
+ except Exception as exc:
397
+ raise SystemLoadError(
398
+ f"Failed to load prompt {yaml_file}: {exc}"
399
+ ) from exc
400
+
401
+ def _parse_prompt(self, data: dict[str, Any]) -> PromptDefinition:
402
+ return PromptDefinition(
403
+ kind=data.get("kind", "prompt"),
404
+ name=data["name"],
405
+ prompt_template=data["prompt_template"],
406
+ id=data.get("id"),
407
+ description=data.get("description"),
408
+ version=str(data.get("version", "1.0")),
409
+ llm=data.get("llm"), # PromptDefinition.__post_init__ converts dict
410
+ )
411
+
412
+ # ------------------------------------------------------------------
413
+ # Sources
414
+ # ------------------------------------------------------------------
415
+
416
+ def _load_sources(self, system: System, sources_dir: Path) -> None:
417
+ if not sources_dir.exists():
418
+ return
419
+ for yaml_file in sorted(sources_dir.glob("*.yaml")):
420
+ try:
421
+ data = self._read_yaml(yaml_file)
422
+ source = self._parse_source(data)
423
+ source_id = source.id or source.name
424
+ system.sources[source_id] = source
425
+ except SystemLoadError:
426
+ raise
427
+ except Exception as exc:
428
+ raise SystemLoadError(
429
+ f"Failed to load source {yaml_file}: {exc}"
430
+ ) from exc
431
+
432
+ def _parse_source(self, data: dict[str, Any]) -> SourceDefinition:
433
+ return SourceDefinition(
434
+ kind=data.get("kind", "source"),
435
+ name=data["name"],
436
+ id=data.get("id"),
437
+ display_name=data.get("display_name"),
438
+ edition=data.get("edition"),
439
+ default=data.get("default"),
440
+ publisher=data.get("publisher"),
441
+ description=data.get("description"),
442
+ source_url=data.get("source_url"),
443
+ version=str(data.get("version", "1.0")),
444
+ )
@@ -0,0 +1,97 @@
1
+ """Compendium definition models for GRIMOIRE runner."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class CompendiumDefinition:
10
+ """Compendium definition containing collections of game content."""
11
+
12
+ kind: str
13
+ id: str # Unique identifier for the compendium
14
+ name: str # Human-readable name
15
+ model: str # The model type that entries conform to
16
+ entries: dict[str, dict[str, Any]] = field(default_factory=dict)
17
+
18
+ def get_entry(self, entry_id: str) -> dict[str, Any] | None:
19
+ """Get an entry by ID."""
20
+ return self.entries.get(entry_id)
21
+
22
+ def list_entries(self) -> list[str]:
23
+ """Get a list of all entry IDs."""
24
+ return list(self.entries.keys())
25
+
26
+ def filter_entries(
27
+ self, filter_func: Callable[[dict[str, Any]], bool]
28
+ ) -> dict[str, dict[str, Any]]:
29
+ """Filter entries based on a function."""
30
+ return {
31
+ entry_id: entry_data
32
+ for entry_id, entry_data in self.entries.items()
33
+ if filter_func(entry_data)
34
+ }
35
+
36
+ def search_entries(
37
+ self, search_term: str, fields: list[str] | None = None
38
+ ) -> dict[str, dict[str, Any]]:
39
+ """Search entries by text in specified fields (or all string fields)."""
40
+ search_term = search_term.lower()
41
+ results = {}
42
+
43
+ for entry_id, entry_data in self.entries.items():
44
+ if self._entry_matches_search(entry_data, search_term, fields):
45
+ results[entry_id] = entry_data
46
+
47
+ return results
48
+
49
+ def _entry_matches_search(
50
+ self,
51
+ entry_data: dict[str, Any],
52
+ search_term: str,
53
+ fields: list[str] | None = None,
54
+ ) -> bool:
55
+ """Check if an entry matches the search term."""
56
+
57
+ def search_in_value(value: Any) -> bool:
58
+ if isinstance(value, str):
59
+ return search_term in value.lower()
60
+ elif isinstance(value, dict):
61
+ return any(search_in_value(v) for v in value.values())
62
+ elif isinstance(value, list):
63
+ return any(search_in_value(item) for item in value)
64
+ return False
65
+
66
+ if fields:
67
+ # Search only in specified fields
68
+ for field in fields:
69
+ if field in entry_data and search_in_value(entry_data[field]):
70
+ return True
71
+ else:
72
+ # Search in all fields
73
+ return search_in_value(entry_data)
74
+
75
+ return False
76
+
77
+ def validate(self) -> list[str]:
78
+ """Validate the compendium definition and return any errors."""
79
+ errors = []
80
+
81
+ # Validate required fields
82
+ if self.kind != "compendium":
83
+ errors.append(f"Compendium kind must be 'compendium', got '{self.kind}'")
84
+ if not self.id:
85
+ errors.append("Compendium id is required")
86
+ if not self.name:
87
+ errors.append("Compendium name is required")
88
+ if not self.model:
89
+ errors.append("Compendium model is required")
90
+
91
+ # Validate entry IDs are unique (they are by dict nature, but check for empty)
92
+ if not self.entries:
93
+ errors.append("Compendium must have at least one entry")
94
+
95
+ # TODO: Validate entries against model definition
96
+
97
+ return errors