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 +0 -0
- grimoire/loader.py +444 -0
- grimoire/models/compendium_definition.py +97 -0
- grimoire/models/flow.py +342 -0
- grimoire/models/model_definition.py +216 -0
- grimoire/models/prompt.py +68 -0
- grimoire/models/source.py +31 -0
- grimoire/models/system.py +146 -0
- grimoire/models/table.py +159 -0
- grimoire_spec-0.1.1.dist-info/METADATA +11 -0
- grimoire_spec-0.1.1.dist-info/RECORD +12 -0
- grimoire_spec-0.1.1.dist-info/WHEEL +4 -0
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
|