bookwright-cli 0.2.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 (149) hide show
  1. bookwright/__init__.py +3 -0
  2. bookwright/__main__.py +6 -0
  3. bookwright/cli.py +19 -0
  4. bookwright/commands/__init__.py +0 -0
  5. bookwright/commands/_envelope.py +36 -0
  6. bookwright/commands/check.py +75 -0
  7. bookwright/commands/graph/__init__.py +23 -0
  8. bookwright/commands/graph/build.py +157 -0
  9. bookwright/commands/graph/envelope.py +26 -0
  10. bookwright/commands/graph/query.py +98 -0
  11. bookwright/commands/init/__init__.py +5 -0
  12. bookwright/commands/init/conflict.py +107 -0
  13. bookwright/commands/init/envelope.py +322 -0
  14. bookwright/commands/init/git.py +96 -0
  15. bookwright/commands/init/main.py +263 -0
  16. bookwright/commands/init/resolve.py +193 -0
  17. bookwright/commands/init/scaffold.py +242 -0
  18. bookwright/commands/init/validate.py +172 -0
  19. bookwright/commands/integration/__init__.py +22 -0
  20. bookwright/commands/integration/use.py +120 -0
  21. bookwright/commands/validate.py +160 -0
  22. bookwright/commands/version.py +35 -0
  23. bookwright/core/__init__.py +35 -0
  24. bookwright/core/_blocks.py +239 -0
  25. bookwright/core/_build.py +154 -0
  26. bookwright/core/_research_block.py +56 -0
  27. bookwright/core/_translate.py +90 -0
  28. bookwright/core/errors.py +127 -0
  29. bookwright/core/iso639_1.py +200 -0
  30. bookwright/core/manifest.py +343 -0
  31. bookwright/errors.py +47 -0
  32. bookwright/golem/__init__.py +71 -0
  33. bookwright/golem/base.py +200 -0
  34. bookwright/golem/errors.py +29 -0
  35. bookwright/golem/modules/__init__.py +1 -0
  36. bookwright/golem/modules/character.py +109 -0
  37. bookwright/golem/modules/event.py +91 -0
  38. bookwright/golem/modules/feature.py +161 -0
  39. bookwright/golem/modules/inference.py +41 -0
  40. bookwright/golem/modules/narrative.py +55 -0
  41. bookwright/golem/modules/provenance.py +197 -0
  42. bookwright/golem/modules/relationship.py +38 -0
  43. bookwright/golem/modules/setting.py +30 -0
  44. bookwright/golem/namespaces.py +332 -0
  45. bookwright/golem/serialize.py +25 -0
  46. bookwright/golem/slug.py +22 -0
  47. bookwright/indexers/__init__.py +47 -0
  48. bookwright/indexers/base.py +55 -0
  49. bookwright/indexers/errors.py +80 -0
  50. bookwright/indexers/rdflib_indexer.py +89 -0
  51. bookwright/integrations/__init__.py +155 -0
  52. bookwright/integrations/base.py +117 -0
  53. bookwright/integrations/claude/__init__.py +29 -0
  54. bookwright/integrations/constants.py +38 -0
  55. bookwright/integrations/descriptions.py +48 -0
  56. bookwright/integrations/errors.py +170 -0
  57. bookwright/integrations/generic/__init__.py +56 -0
  58. bookwright/integrations/lint.py +160 -0
  59. bookwright/integrations/materialize.py +202 -0
  60. bookwright/integrations/options.py +203 -0
  61. bookwright/io/__init__.py +1 -0
  62. bookwright/io/bible.py +500 -0
  63. bookwright/io/errors.py +98 -0
  64. bookwright/io/frontmatter.py +61 -0
  65. bookwright/io/fs.py +226 -0
  66. bookwright/io/manuscript.py +15 -0
  67. bookwright/io/project.py +21 -0
  68. bookwright/io/report.py +107 -0
  69. bookwright/io/research.py +427 -0
  70. bookwright/resources/__init__.py +1 -0
  71. bookwright/resources/commands/bookwright-analyze.md +66 -0
  72. bookwright/resources/commands/bookwright-bible.md +96 -0
  73. bookwright/resources/commands/bookwright-checklist.md +67 -0
  74. bookwright/resources/commands/bookwright-clarify.md +65 -0
  75. bookwright/resources/commands/bookwright-constitution.md +79 -0
  76. bookwright/resources/commands/bookwright-continuity.md +70 -0
  77. bookwright/resources/commands/bookwright-draft.md +74 -0
  78. bookwright/resources/commands/bookwright-outline.md +71 -0
  79. bookwright/resources/commands/bookwright-research.md +107 -0
  80. bookwright/resources/commands/bookwright-scenes.md +66 -0
  81. bookwright/resources/commands/bookwright-synopsis.md +67 -0
  82. bookwright/resources/commands/bookwright-verify.md +136 -0
  83. bookwright/resources/commands/references/golem-character.md +65 -0
  84. bookwright/resources/commands/references/golem-events-timeline.md +56 -0
  85. bookwright/resources/commands/references/golem-relationships.md +53 -0
  86. bookwright/resources/commands/references/greimas-actants.md +57 -0
  87. bookwright/resources/commands/references/pending-protocol.md +72 -0
  88. bookwright/resources/commands/references/propp-functions.md +54 -0
  89. bookwright/resources/commands/references/research-format.md +136 -0
  90. bookwright/resources/project/.bookwright/cache/.gitkeep +0 -0
  91. bookwright/resources/project/.bookwright/schema/.gitkeep +0 -0
  92. bookwright/resources/project/.bookwright/templates/.gitkeep +0 -0
  93. bookwright/resources/project/.gitignore +23 -0
  94. bookwright/resources/project/README.md.j2 +40 -0
  95. bookwright/resources/project/__init__.py +6 -0
  96. bookwright/resources/project/bible/characters/.gitkeep +0 -0
  97. bookwright/resources/project/bible/constitution.md.j2 +74 -0
  98. bookwright/resources/project/bible/glossary.md +36 -0
  99. bookwright/resources/project/bible/locations/.gitkeep +0 -0
  100. bookwright/resources/project/bible/pov-structure.md +43 -0
  101. bookwright/resources/project/bible/relationships.md +36 -0
  102. bookwright/resources/project/bible/research/_index.md +28 -0
  103. bookwright/resources/project/bible/research/sources.md +23 -0
  104. bookwright/resources/project/bible/settings/.gitkeep +0 -0
  105. bookwright/resources/project/bible/subplots.md +35 -0
  106. bookwright/resources/project/bible/themes.md +36 -0
  107. bookwright/resources/project/bible/timeline.md +38 -0
  108. bookwright/resources/project/manuscript/.gitkeep +0 -0
  109. bookwright/resources/project/outline/arcs.md +34 -0
  110. bookwright/resources/project/outline/scenes.md +31 -0
  111. bookwright/resources/project/outline/structure.md +35 -0
  112. bookwright/resources/project/outline/synopsis.md +25 -0
  113. bookwright/resources/schemas/__init__.py +19 -0
  114. bookwright/resources/schemas/golem-1.1/VERSION +1 -0
  115. bookwright/resources/schemas/golem-1.1/golem.ttl +1947 -0
  116. bookwright/resources/schemas/golem-1.1/version.json +8 -0
  117. bookwright/resources/templates/__init__.py +1 -0
  118. bookwright/resources/templates/bible/character.md.tmpl +63 -0
  119. bookwright/resources/templates/bible/location.md.tmpl +37 -0
  120. bookwright/resources/templates/bible/research/_index.md.tmpl +25 -0
  121. bookwright/resources/templates/bible/research/sources.md.tmpl +21 -0
  122. bookwright/resources/templates/bible/research/tema.md.tmpl +37 -0
  123. bookwright/resources/templates/bible/setting.md.tmpl +38 -0
  124. bookwright/resources/templates/manifest.template.toml +79 -0
  125. bookwright/resources/templates/manuscript/chapter.md.tmpl +36 -0
  126. bookwright/resources/templates/scenes/scene.md.tmpl +37 -0
  127. bookwright/resources/vocabularies/__init__.py +6 -0
  128. bookwright/resources/vocabularies/greimas.ttl +4 -0
  129. bookwright/resources/vocabularies/propp.ttl +4 -0
  130. bookwright/resources/vocabularies/sources.ttl +82 -0
  131. bookwright/validation/__init__.py +33 -0
  132. bookwright/validation/anchor_queries.py +223 -0
  133. bookwright/validation/base.py +233 -0
  134. bookwright/validation/queries.py +197 -0
  135. bookwright/validation/registry.py +185 -0
  136. bookwright/validation/report.py +106 -0
  137. bookwright/validation/runner.py +65 -0
  138. bookwright/validation/validators/__init__.py +9 -0
  139. bookwright/validation/validators/character_presence.py +202 -0
  140. bookwright/validation/validators/factual_anchor.py +291 -0
  141. bookwright/validation/validators/focalization.py +152 -0
  142. bookwright/validation/validators/setting_continuity.py +100 -0
  143. bookwright/validation/validators/temporal.py +277 -0
  144. bookwright_cli-0.2.0.dist-info/METADATA +218 -0
  145. bookwright_cli-0.2.0.dist-info/RECORD +149 -0
  146. bookwright_cli-0.2.0.dist-info/WHEEL +4 -0
  147. bookwright_cli-0.2.0.dist-info/entry_points.txt +2 -0
  148. bookwright_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  149. bookwright_cli-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,109 @@
1
+ """Character module: agents and objects of the storyworld (GOLEM § character)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable
6
+ from typing import ClassVar, TypeVar
7
+
8
+ from pydantic import PrivateAttr
9
+ from rdflib.term import URIRef
10
+
11
+ from bookwright.golem.base import CrossRef, DerivedAssertion, GolemEntity, SluggedEntity
12
+ from bookwright.golem.modules.feature import BioKind, CharacterFeature, CharacterRole
13
+ from bookwright.golem.namespaces import CLASS_IRI, HAS_FEATURE, PLAYS
14
+
15
+ _Node = TypeVar("_Node", bound=GolemEntity)
16
+
17
+
18
+ def _dedup_nodes(texts: Iterable[str], factory: Callable[[str], _Node]) -> tuple[_Node, ...]:
19
+ """Materialize one node per text, dropping later duplicates by URI (FR-021)."""
20
+ nodes: list[_Node] = []
21
+ seen: set[URIRef] = set()
22
+ for text in texts:
23
+ node = factory(text)
24
+ if node.uri not in seen:
25
+ seen.add(node.uri)
26
+ nodes.append(node)
27
+ return tuple(nodes)
28
+
29
+
30
+ class Character(SluggedEntity):
31
+ """A character (``golem:G1_Character``).
32
+
33
+ Besides its identity, a character may carry the documented frontmatter:
34
+ ``born`` / ``died`` years, free-text ``features``, and
35
+ ``narrative_roles``. Each is materialized — once, deterministically, at
36
+ construction — as a character-scoped node under
37
+ :mod:`bookwright.golem.modules.feature`, linked by a frozen predicate
38
+ (``golem:GP0_has_feature`` for features, ``edns:plays`` for roles). A
39
+ character built with none of the four attributes has empty node tuples and
40
+ therefore emits only its ``rdf:type`` assertion.
41
+ """
42
+
43
+ golem_class: ClassVar[URIRef] = CLASS_IRI["Character"]
44
+ path_segment: ClassVar[str] = "character"
45
+ cross_refs: ClassVar[tuple[CrossRef, ...]] = (
46
+ CrossRef("_feature_nodes", HAS_FEATURE, multi=True, owned=True),
47
+ CrossRef("_role_nodes", PLAYS, multi=True, owned=True),
48
+ )
49
+
50
+ born: int | None = None
51
+ died: int | None = None
52
+ features: tuple[str, ...] = ()
53
+ narrative_roles: tuple[str, ...] = ()
54
+
55
+ _feature_nodes: tuple[CharacterFeature, ...] = PrivateAttr(default=())
56
+ _role_nodes: tuple[CharacterRole, ...] = PrivateAttr(default=())
57
+
58
+ def model_post_init(self, __context: object) -> None:
59
+ super().model_post_init(__context) # fixes slug + identity URI first
60
+
61
+ # Biographical features live under `feature/bio/…`, structurally apart
62
+ # from the free-text slug space, so they never collide with free-text
63
+ # values and only the free-text values need URI dedup (FR-021).
64
+ biographical: list[CharacterFeature] = []
65
+ if self.born is not None:
66
+ biographical.append(self._biographical("birth", self.born))
67
+ if self.died is not None:
68
+ biographical.append(self._biographical("death", self.died))
69
+ free_text = _dedup_nodes(
70
+ self.features,
71
+ lambda text: CharacterFeature(
72
+ uri_base=self.uri_base, character_uri=self.uri, label=text
73
+ ),
74
+ )
75
+ self._feature_nodes = (*biographical, *free_text)
76
+ self._role_nodes = _dedup_nodes(
77
+ self.narrative_roles,
78
+ lambda text: CharacterRole(uri_base=self.uri_base, character_uri=self.uri, label=text),
79
+ )
80
+
81
+ def _biographical(self, kind: BioKind, year: int) -> CharacterFeature:
82
+ return CharacterFeature(
83
+ uri_base=self.uri_base, character_uri=self.uri, kind=kind, year=year
84
+ )
85
+
86
+ def derived_assertions(self) -> Iterable[DerivedAssertion]:
87
+ """One :class:`DerivedAssertion` per derived assertion, each tagged with
88
+ the frontmatter key it came from so the indexer can resolve a source line:
89
+ biographical features → ``born`` / ``died``, free-text → ``features``,
90
+ roles → ``narrative_roles``. The identity assertion is file-level
91
+ (``source_field`` ``None``).
92
+
93
+ Overrides the declarative base because ``cross_refs`` cannot express this
94
+ fan-out: a single ``_feature_nodes`` tuple carries three distinct origins
95
+ (``born`` / ``died`` / ``features``), which only this class can untangle —
96
+ a biographical node is recognised by its ``kind``."""
97
+ yield DerivedAssertion(self.uri, self.uri, None)
98
+ for feature in self._feature_nodes:
99
+ field = {"birth": "born", "death": "died"}.get(feature.kind or "", "features")
100
+ yield DerivedAssertion(self.uri, feature.uri, field)
101
+ for role in self._role_nodes:
102
+ yield DerivedAssertion(self.uri, role.uri, "narrative_roles")
103
+
104
+
105
+ class Object(SluggedEntity):
106
+ """A storyworld object (``golem:G16_Object``). Identity only in v0."""
107
+
108
+ golem_class: ClassVar[URIRef] = CLASS_IRI["Object"]
109
+ path_segment: ClassVar[str] = "object"
@@ -0,0 +1,91 @@
1
+ """Event module: narrative events and the psychological states they touch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import ClassVar
7
+
8
+ from rdflib.namespace import RDF
9
+ from rdflib.term import URIRef
10
+
11
+ from bookwright.golem.base import CrossRef, GolemEntity, SluggedEntity, Triple
12
+ from bookwright.golem.modules.feature import Dimension
13
+ from bookwright.golem.namespaces import (
14
+ CLASS_IRI,
15
+ DURATION,
16
+ GENERICALLY_DEPENDENT_ON,
17
+ HAS_DIMENSION,
18
+ HAS_TYPE,
19
+ PARTICIPANT,
20
+ TEMPORAL_LOCATION,
21
+ TEMPORAL_RELATIONS,
22
+ )
23
+
24
+
25
+ class NarrativeEvent(SluggedEntity):
26
+ """A narrative event (``golem:G5_Narrative_Event``).
27
+
28
+ Each participant is linked by one ``dlp:participant`` triple (FR-015). An event
29
+ may additionally carry a multi-year time **interval** (``begin`` / ``end`` years)
30
+ and any of the five qualitative temporal relations to other events, which the
31
+ ``temporal`` validator (iteration 11) reasons over. The relations are ordinary
32
+ ``cross_refs`` (one frozen ``TR:*`` predicate each); the interval needs a custom
33
+ ``to_triples`` override because its typed-boundary + dimension shape is outside
34
+ what ``cross_refs`` can express (research D11). An event with no interval and no
35
+ relations behaves exactly as before — only its ``rdf:type`` and any participants.
36
+ """
37
+
38
+ golem_class: ClassVar[URIRef] = CLASS_IRI["NarrativeEvent"]
39
+ path_segment: ClassVar[str] = "event"
40
+ cross_refs: ClassVar[tuple[CrossRef, ...]] = (
41
+ CrossRef("participants", PARTICIPANT, multi=True),
42
+ *(CrossRef(rel.name, rel.predicate, multi=True) for rel in TEMPORAL_RELATIONS),
43
+ )
44
+
45
+ participants: tuple[GolemEntity | URIRef, ...] = ()
46
+ begin: int | None = None
47
+ end: int | None = None
48
+ follows: tuple[GolemEntity | URIRef, ...] = ()
49
+ precedes: tuple[GolemEntity | URIRef, ...] = ()
50
+ overlaps: tuple[GolemEntity | URIRef, ...] = ()
51
+ includes: tuple[GolemEntity | URIRef, ...] = ()
52
+ included_in: tuple[GolemEntity | URIRef, ...] = ()
53
+
54
+ def to_triples(self) -> Iterable[Triple]:
55
+ """The base emission (type + participants + the five relation edges) followed
56
+ by the closure-safe interval triples for any present begin/end boundary."""
57
+ yield from super().to_triples()
58
+ yield from self._interval_triples()
59
+
60
+ def _interval_triples(self) -> Iterable[Triple]:
61
+ if self.begin is None and self.end is None:
62
+ return
63
+ span_uri = URIRef(f"{self.uri}/time-span")
64
+ time_interval = CLASS_IRI["TimeInterval"]
65
+ yield (self.uri, DURATION, span_uri)
66
+ yield (span_uri, RDF.type, time_interval)
67
+ for kind, year in (("begin", self.begin), ("end", self.end)):
68
+ if year is None:
69
+ continue
70
+ boundary_uri = URIRef(f"{span_uri}/{kind}")
71
+ type_uri = URIRef(f"{self.uri_base}type/{kind}")
72
+ dimension = Dimension(uri_base=self.uri_base, feature_uri=boundary_uri, year=year)
73
+ yield (span_uri, TEMPORAL_LOCATION, boundary_uri)
74
+ yield (boundary_uri, RDF.type, time_interval)
75
+ yield (boundary_uri, HAS_TYPE, type_uri)
76
+ yield (type_uri, RDF.type, CLASS_IRI["Type"])
77
+ yield (boundary_uri, HAS_DIMENSION, dimension.uri)
78
+ yield from dimension.to_triples()
79
+
80
+
81
+ class PsychologicalState(SluggedEntity):
82
+ """A psychological state (``golem:G3_Psychological_State``).
83
+
84
+ When set, the state is ``dlp:generically-dependent-on`` its bearer (FR-015).
85
+ """
86
+
87
+ golem_class: ClassVar[URIRef] = CLASS_IRI["PsychologicalState"]
88
+ path_segment: ClassVar[str] = "psychological-state"
89
+ cross_refs: ClassVar[tuple[CrossRef, ...]] = (CrossRef("bearer", GENERICALLY_DEPENDENT_ON),)
90
+
91
+ bearer: GolemEntity | URIRef | None = None
@@ -0,0 +1,161 @@
1
+ """Feature module: character-scoped attribute carriers.
2
+
3
+ These typed nodes hang off a :class:`~bookwright.golem.modules.character.Character`
4
+ and carry the documented frontmatter that the identity-only model could not:
5
+ free-text features, biographical years (``born`` / ``died``), and narrative
6
+ roles. They are **not** narrative concepts — they are excluded from the
7
+ ``CONCEPTS`` registry (SC-001) — but they emit only frozen GOLEM / CIDOC-CRM /
8
+ DOLCE terms (FR-020), and every node carries a deterministic, character-scoped
9
+ URI built from its owner's URI plus a fixed suffix (never a blank node, FR-021).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterable
15
+ from typing import ClassVar, Literal
16
+
17
+ from pydantic import model_validator
18
+ from rdflib.namespace import RDF, RDFS, XSD
19
+ from rdflib.term import Literal as RdfLiteral
20
+ from rdflib.term import URIRef
21
+
22
+ from bookwright.golem.base import GolemEntity, Triple
23
+ from bookwright.golem.namespaces import (
24
+ CLASS_IRI,
25
+ HAS_DIMENSION,
26
+ HAS_TYPE,
27
+ HAS_VALUE,
28
+ )
29
+ from bookwright.golem.slug import make_slug
30
+
31
+ BioKind = Literal["birth", "death"]
32
+ """The two biographical feature kinds; each maps to a shared E55_Type individual."""
33
+
34
+
35
+ def gyear_literal(year: int) -> RdfLiteral:
36
+ """Format an integer year as a lexically valid ``xsd:gYear`` literal (FR-019).
37
+
38
+ XSD requires the year part to be at least four digits, with an optional
39
+ leading ``-`` for BCE years. Plain ``str(year)`` is invalid for years < 1000
40
+ or BCE (e.g. ``"800"``, ``"-44"``), which the iteration-10 temporal queries —
41
+ the whole reason for ``gYear`` over a plain string — would then reject. Pad
42
+ the magnitude to four digits and preserve the sign instead, so ``800`` →
43
+ ``"0800"`` and ``-44`` → ``"-0044"`` while ``1828`` stays ``"1828"``.
44
+ """
45
+ sign = "-" if year < 0 else ""
46
+ return RdfLiteral(f"{sign}{abs(year):04d}", datatype=XSD.gYear)
47
+
48
+
49
+ class Dimension(GolemEntity):
50
+ """A measurement (``crm:E54_Dimension``) carrying a biographical year.
51
+
52
+ URI is ``{feature.uri}/dimension``; the year is emitted as an ``xsd:gYear``
53
+ literal (never ``xsd:integer`` or an untyped string — FR-019), which is what
54
+ makes iteration-10's temporal queries answerable.
55
+ """
56
+
57
+ golem_class: ClassVar[URIRef] = CLASS_IRI["Dimension"]
58
+
59
+ feature_uri: URIRef
60
+ year: int
61
+
62
+ def model_post_init(self, __context: object) -> None:
63
+ self._uri = URIRef(f"{self.feature_uri}/dimension")
64
+
65
+ def to_triples(self) -> Iterable[Triple]:
66
+ yield (self.uri, RDF.type, self.golem_class)
67
+ yield (self.uri, HAS_VALUE, gyear_literal(self.year))
68
+
69
+
70
+ class CharacterFeature(GolemEntity):
71
+ """A character feature (``golem:G17_Character_Feature``), one of two variants.
72
+
73
+ - **free-text** — supply ``label``; URI ``{character.uri}/feature/{slug(label)}``,
74
+ emits the type assertion + ``rdfs:label``.
75
+ - **biographical** — supply ``kind`` (``"birth"``/``"death"``) and ``year``;
76
+ URI ``{character.uri}/feature/bio/{kind}`` (the ``bio/`` sub-segment keeps
77
+ the birth/death token out of the free-text slug space, so the two variants
78
+ can never collide on one character), emits the type assertion, a
79
+ ``crm:P2_has_type`` link to the shared ``{uri_base}type/{kind}`` E55_Type
80
+ individual, and a ``crm:P43_has_dimension`` link to its :class:`Dimension`.
81
+
82
+ Exactly one variant must be supplied. A free-text ``label`` that slugs to
83
+ empty raises :class:`~bookwright.golem.errors.EmptySlugError` (FR-021).
84
+ """
85
+
86
+ golem_class: ClassVar[URIRef] = CLASS_IRI["CharacterFeature"]
87
+
88
+ character_uri: URIRef
89
+ label: str | None = None
90
+ kind: BioKind | None = None
91
+ year: int | None = None
92
+
93
+ @model_validator(mode="before")
94
+ @classmethod
95
+ def _exactly_one_variant(cls, data: dict[str, object]) -> dict[str, object]:
96
+ # Runs *before* identity construction in ``model_post_init`` (which
97
+ # builds the URI from the chosen variant), so the variant invariant is
98
+ # enforced by a real ``ValidationError`` rather than the type-narrowing
99
+ # assert at the bottom of this class. That assert is then never the line
100
+ # that rejects bad input, so it stays correct under ``python -O``. ``data``
101
+ # is always the raw kwargs dict: this frozen model is only ever built via
102
+ # ``CharacterFeature(...)``, never re-validated from an existing instance.
103
+ kind, label, year = data.get("kind"), data.get("label"), data.get("year")
104
+ if kind is not None:
105
+ # Biographical: a year is mandatory, a free-text label is forbidden.
106
+ if label is not None:
107
+ raise ValueError("biographical CharacterFeature must not also carry a `label`")
108
+ if year is None:
109
+ raise ValueError("biographical CharacterFeature requires a `year`")
110
+ else:
111
+ # Free-text: a label is mandatory; a stray `year` is forbidden rather
112
+ # than silently dropped, since only the biographical variant emits it.
113
+ if label is None:
114
+ raise ValueError("CharacterFeature requires either `label` or (`kind` + `year`)")
115
+ if year is not None:
116
+ raise ValueError("free-text CharacterFeature must not carry a `year`")
117
+ return data
118
+
119
+ def model_post_init(self, __context: object) -> None:
120
+ if self.kind is not None:
121
+ # Biographical features live under a `bio/` sub-segment: a free-text
122
+ # slug never contains `/`, so it can never collide with the
123
+ # birth/death token on the same character (FR-021).
124
+ self._uri = URIRef(f"{self.character_uri}/feature/bio/{self.kind}")
125
+ else:
126
+ assert self.label is not None # guaranteed by _exactly_one_variant
127
+ self._uri = URIRef(f"{self.character_uri}/feature/{make_slug(self.label)}")
128
+
129
+ def to_triples(self) -> Iterable[Triple]:
130
+ yield (self.uri, RDF.type, self.golem_class)
131
+ if self.kind is not None:
132
+ type_uri = URIRef(f"{self.uri_base}type/{self.kind}")
133
+ yield (self.uri, HAS_TYPE, type_uri)
134
+ yield (type_uri, RDF.type, CLASS_IRI["Type"])
135
+ assert self.year is not None # guaranteed by _exactly_one_variant
136
+ dimension = Dimension(uri_base=self.uri_base, feature_uri=self.uri, year=self.year)
137
+ yield (self.uri, HAS_DIMENSION, dimension.uri)
138
+ yield from dimension.to_triples()
139
+ else:
140
+ yield (self.uri, RDFS.label, RdfLiteral(self.label))
141
+
142
+
143
+ class CharacterRole(GolemEntity):
144
+ """A character-scoped narrative role (``golem:G11_Narrative_Role``).
145
+
146
+ URI is ``{character.uri}/role/{slug(label)}``; emits the type assertion and
147
+ the role text on ``rdfs:label``. Distinct from the top-level ``NarrativeRole``
148
+ concept: this node is inlined under a character and is not in ``CONCEPTS``.
149
+ """
150
+
151
+ golem_class: ClassVar[URIRef] = CLASS_IRI["NarrativeRole"]
152
+
153
+ character_uri: URIRef
154
+ label: str
155
+
156
+ def model_post_init(self, __context: object) -> None:
157
+ self._uri = URIRef(f"{self.character_uri}/role/{make_slug(self.label)}")
158
+
159
+ def to_triples(self) -> Iterable[Triple]:
160
+ yield (self.uri, RDF.type, self.golem_class)
161
+ yield (self.uri, RDFS.label, RdfLiteral(self.label))
@@ -0,0 +1,41 @@
1
+ """Inference module: provenance for inferred attributes (CIDOC-CRM E13)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from rdflib.term import URIRef
8
+
9
+ from bookwright.golem.base import CrossRef, GolemEntity, MintedEntity
10
+ from bookwright.golem.namespaces import (
11
+ ASSIGNED,
12
+ ASSIGNED_ATTRIBUTE_TO,
13
+ CLASS_IRI,
14
+ REFERS_TO,
15
+ USED_SPECIFIC_OBJECT,
16
+ )
17
+
18
+
19
+ class AttributeAssignment(MintedEntity):
20
+ """A provenance record for an inferred attribute (``crm:E13_Attribute_Assignment``).
21
+
22
+ Constructed without a ``name``: its identity token is the time-ordered uuid7
23
+ minted by :class:`~bookwright.golem.base.MintedEntity`, so two assignments
24
+ created in sequence sort in creation order (FR-013, D3). The ``source`` path is
25
+ stored and emitted verbatim as an ``xsd:string`` literal (FR-009, D7);
26
+ ``premise`` is omitted from the triples when ``None``.
27
+ """
28
+
29
+ golem_class: ClassVar[URIRef] = CLASS_IRI["AttributeAssignment"]
30
+ path_segment: ClassVar[str] = "assertion"
31
+ cross_refs: ClassVar[tuple[CrossRef, ...]] = (
32
+ CrossRef("target", ASSIGNED_ATTRIBUTE_TO),
33
+ CrossRef("attribute", ASSIGNED),
34
+ CrossRef("source", USED_SPECIFIC_OBJECT, literal=True),
35
+ CrossRef("premise", REFERS_TO),
36
+ )
37
+
38
+ target: GolemEntity | URIRef
39
+ attribute: GolemEntity | URIRef
40
+ source: str
41
+ premise: GolemEntity | URIRef | None = None
@@ -0,0 +1,55 @@
1
+ """Narrative module: units, their functions/roles, and ordered sequences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from rdflib.term import URIRef
8
+
9
+ from bookwright.golem.base import CrossRef, GolemEntity, SluggedEntity
10
+ from bookwright.golem.namespaces import CLASS_IRI, PROPER_PART, REFERS_TO
11
+
12
+
13
+ class NarrativeUnit(SluggedEntity):
14
+ """A narrative unit (``golem:G9_Narrative_Unit``).
15
+
16
+ Refers to each of its narrative functions and roles (``crm:P67_refers_to``).
17
+ """
18
+
19
+ golem_class: ClassVar[URIRef] = CLASS_IRI["NarrativeUnit"]
20
+ path_segment: ClassVar[str] = "narrative-unit"
21
+ cross_refs: ClassVar[tuple[CrossRef, ...]] = (
22
+ CrossRef("functions", REFERS_TO, multi=True),
23
+ CrossRef("roles", REFERS_TO, multi=True),
24
+ )
25
+
26
+ functions: tuple[GolemEntity | URIRef, ...] = ()
27
+ roles: tuple[GolemEntity | URIRef, ...] = ()
28
+
29
+
30
+ class NarrativeFunction(SluggedEntity):
31
+ """A narrative function (``golem:G10_Narrative_Function``). Identity only."""
32
+
33
+ golem_class: ClassVar[URIRef] = CLASS_IRI["NarrativeFunction"]
34
+ path_segment: ClassVar[str] = "narrative-function"
35
+
36
+
37
+ class NarrativeRole(SluggedEntity):
38
+ """A narrative role (``golem:G11_Narrative_Role``). Identity only."""
39
+
40
+ golem_class: ClassVar[URIRef] = CLASS_IRI["NarrativeRole"]
41
+ path_segment: ClassVar[str] = "narrative-role"
42
+
43
+
44
+ class NarrativeSequence(SluggedEntity):
45
+ """A narrative sequence (``golem:G7_Narrative_Sequence``).
46
+
47
+ Emits one ``dlp:proper-part`` triple per member unit, in declared order
48
+ (FR-015). RDF is unordered; the ordering is the caller's tuple order.
49
+ """
50
+
51
+ golem_class: ClassVar[URIRef] = CLASS_IRI["NarrativeSequence"]
52
+ path_segment: ClassVar[str] = "narrative-sequence"
53
+ cross_refs: ClassVar[tuple[CrossRef, ...]] = (CrossRef("units", PROPER_PART, multi=True),)
54
+
55
+ units: tuple[GolemEntity | URIRef, ...] = ()
@@ -0,0 +1,197 @@
1
+ """Provenance module: research entities (Source / Finding / Anchor).
2
+
3
+ The three research concepts of iteration 012 (design § 20). They reuse the GOLEM
4
+ machinery — frozen Pydantic identity, deterministic URIs, ``to_triples()`` — but
5
+ introduce **no new GOLEM/ontology class** (Constitution X, FR-001):
6
+
7
+ - :class:`Source` is typed purely via ``crm:E55_Type`` (``crm:P2_has_type`` →
8
+ a ``sources.ttl`` individual); it emits **no** ``rdf:type`` (research D2).
9
+ - :class:`Finding` and :class:`Anchor` reify on ``crm:E13_Attribute_Assignment``
10
+ (the same class the Inference module uses), staying distinguishable from inferred
11
+ assertions by their URI segment and their ``bw:`` predicates (FR-018, research D9).
12
+
13
+ All Bookwright (``bw:``) predicates and the source-type / reliability individuals
14
+ are declared in ``resources/vocabularies/sources.ttl``, never in the frozen
15
+ ``golem.ttl``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import datetime
21
+ from collections.abc import Iterable
22
+ from typing import ClassVar, Literal
23
+
24
+ from pydantic import field_validator
25
+ from rdflib.namespace import RDF, XSD
26
+ from rdflib.term import Literal as RdfLiteral
27
+ from rdflib.term import URIRef
28
+
29
+ from bookwright.golem.base import MintedEntity, SluggedEntity, Triple
30
+ from bookwright.golem.modules.feature import gyear_literal
31
+ from bookwright.golem.namespaces import (
32
+ ASSIGNED_ATTRIBUTE_TO,
33
+ BEGIN_OF_BEGIN,
34
+ BW_ACCESS_DATE,
35
+ BW_ASSERTED_BY,
36
+ BW_AUTHOR,
37
+ BW_CLAIM,
38
+ BW_CONSTRAINS,
39
+ BW_OPEN,
40
+ BW_ORIGINAL_LANGUAGE,
41
+ BW_ORIGINAL_QUOTE,
42
+ BW_PROMOTES,
43
+ BW_REFERENCE,
44
+ BW_RELIABILITY,
45
+ BW_RELIABILITY_JUSTIFICATION,
46
+ BW_SUPPORTED_BY,
47
+ BW_TRANSLATION,
48
+ CLASS_IRI,
49
+ E52_TIME_SPAN,
50
+ END_OF_END,
51
+ HAS_TIME_SPAN,
52
+ HAS_TYPE,
53
+ RELIABILITY_IRI,
54
+ SOURCE_TYPE_IRI,
55
+ )
56
+
57
+ SourceType = Literal[
58
+ "primaria", "secundaria", "oficial", "académica", "periodística", "testimonial"
59
+ ]
60
+ """The six controlled source-type values (FR-003). The `Literal` is the enforcement
61
+ point — an out-of-vocabulary value raises a `ValidationError` (research D4)."""
62
+
63
+ Reliability = Literal["alta", "media", "baja"]
64
+ """The three controlled reliability values (FR-004)."""
65
+
66
+
67
+ def _string(value: str) -> RdfLiteral:
68
+ """An ``xsd:string`` literal (the default datatype for every prose facet)."""
69
+ return RdfLiteral(value, datatype=XSD.string)
70
+
71
+
72
+ class Source(SluggedEntity):
73
+ """A research source, typed via ``crm:E55_Type`` — **no** ``rdf:type`` (D2).
74
+
75
+ Its identity token is the ASCII slug of ``name`` (segment ``source``), like
76
+ :class:`~bookwright.golem.modules.character.Character`. ``type`` and
77
+ ``reliability`` are controlled vocabularies enforced by ``Literal`` fields;
78
+ an out-of-vocabulary value is a ``ValidationError`` the reader turns into a
79
+ build-aborting :class:`~bookwright.io.errors.ResearchError` (FR-016).
80
+
81
+ ``translation`` is set by the reader **only** when the source's
82
+ ``original_language`` differs from the book language (research D6); the entity
83
+ itself is language-context-free.
84
+ """
85
+
86
+ # A documented, *unemitted* placeholder: Source overrides ``to_triples`` and
87
+ # yields no ``(uri, rdf:type, golem_class)`` triple. Identity in the graph is
88
+ # ``?s crm:P2_has_type ?t . ?t a crm:E55_Type`` (research D2).
89
+ golem_class: ClassVar[URIRef] = CLASS_IRI["Type"]
90
+ path_segment: ClassVar[str] = "source"
91
+
92
+ reference: str
93
+ author: str
94
+ original_language: str
95
+ type: SourceType
96
+ reliability: Reliability
97
+ reliability_justification: str
98
+ access_date: datetime.date
99
+ original_quote: str
100
+ translation: str | None = None
101
+
102
+ @field_validator("reliability_justification")
103
+ @classmethod
104
+ def _non_empty_justification(cls, value: str) -> str:
105
+ if not value.strip():
106
+ raise ValueError("`reliability_justification` must not be empty")
107
+ return value
108
+
109
+ def to_triples(self) -> Iterable[Triple]:
110
+ """Emit the source's provenance facets — typed via E55, never ``rdf:type``."""
111
+ yield (self.uri, HAS_TYPE, SOURCE_TYPE_IRI[self.type])
112
+ yield (self.uri, BW_RELIABILITY, RELIABILITY_IRI[self.reliability])
113
+ yield (self.uri, BW_RELIABILITY_JUSTIFICATION, _string(self.reliability_justification))
114
+ yield (self.uri, BW_REFERENCE, _string(self.reference))
115
+ yield (self.uri, BW_AUTHOR, _string(self.author))
116
+ yield (self.uri, BW_ORIGINAL_LANGUAGE, _string(self.original_language))
117
+ yield (self.uri, BW_ACCESS_DATE, RdfLiteral(self.access_date, datatype=XSD.date))
118
+ yield (self.uri, BW_ORIGINAL_QUOTE, _string(self.original_quote))
119
+ if self.translation is not None:
120
+ yield (self.uri, BW_TRANSLATION, _string(self.translation))
121
+
122
+
123
+ class Finding(MintedEntity):
124
+ """A research finding, reified on ``crm:E13_Attribute_Assignment`` (FR-006).
125
+
126
+ Its identity token is the time-ordered uuid7 minted once by
127
+ :class:`~bookwright.golem.base.MintedEntity` (segment ``finding``). A **closed**
128
+ finding records a ``claim``, who asserts
129
+ it (``asserted_by``, default ``"author"``), the narrative entity it
130
+ ``bears_on`` (reusing the Inference module's ``crm:P140_assigned_attribute_to``)
131
+ and one or more supporting ``sources``. An **open** finding (FR-008) is an
132
+ unresolved question: ``claim``/``bears_on``/``sources`` may all be empty and the
133
+ entity is still valid, emitting just ``rdf:type`` + ``bw:open true``.
134
+
135
+ ``bw:assertedBy`` is emitted only alongside a ``claim`` — an open question with
136
+ no claim asserts nothing, so it carries no asserter (research D9).
137
+ """
138
+
139
+ golem_class: ClassVar[URIRef] = CLASS_IRI["AttributeAssignment"]
140
+ path_segment: ClassVar[str] = "finding"
141
+
142
+ claim: str | None = None
143
+ asserted_by: str = "author"
144
+ bears_on: URIRef | None = None
145
+ sources: tuple[URIRef, ...] = ()
146
+ open: bool = False
147
+
148
+ def to_triples(self) -> Iterable[Triple]:
149
+ yield (self.uri, RDF.type, self.golem_class)
150
+ if self.claim is not None:
151
+ yield (self.uri, BW_CLAIM, _string(self.claim))
152
+ yield (self.uri, BW_ASSERTED_BY, _string(self.asserted_by))
153
+ if self.bears_on is not None:
154
+ yield (self.uri, ASSIGNED_ATTRIBUTE_TO, self.bears_on)
155
+ for source in self.sources:
156
+ yield (self.uri, BW_SUPPORTED_BY, source)
157
+ if self.open:
158
+ yield (self.uri, BW_OPEN, RdfLiteral(True))
159
+
160
+
161
+ class Anchor(MintedEntity):
162
+ """A binding constraint promoting a :class:`Finding`, reified on E13 (FR-009).
163
+
164
+ Its identity token is the uuid7 minted by
165
+ :class:`~bookwright.golem.base.MintedEntity` (segment ``anchor``). It
166
+ ``promotes`` the finding it derives from and ``constrains`` a narrative entity
167
+ (or the well-known untyped timeline IRI, research D10). When the named target
168
+ does not resolve in the bible, the reader builds the anchor with
169
+ ``constrains=None`` and surfaces a warning — the ``bw:constrains`` triple is
170
+ simply omitted, the build still succeeds (research D12).
171
+
172
+ An optional time-span (``begin``/``end`` years, research D5) is emitted as a
173
+ ``crm:E52_Time-Span`` sub-node under ``crm:P4_has_time-span``; an anchor with
174
+ neither bound emits no time-span (FR-010).
175
+ """
176
+
177
+ golem_class: ClassVar[URIRef] = CLASS_IRI["AttributeAssignment"]
178
+ path_segment: ClassVar[str] = "anchor"
179
+
180
+ promotes: URIRef
181
+ constrains: URIRef | None = None
182
+ begin: int | None = None
183
+ end: int | None = None
184
+
185
+ def to_triples(self) -> Iterable[Triple]:
186
+ yield (self.uri, RDF.type, self.golem_class)
187
+ yield (self.uri, BW_PROMOTES, self.promotes)
188
+ if self.constrains is not None:
189
+ yield (self.uri, BW_CONSTRAINS, self.constrains)
190
+ if self.begin is not None or self.end is not None:
191
+ time_span = URIRef(f"{self.uri}/time-span")
192
+ yield (self.uri, HAS_TIME_SPAN, time_span)
193
+ yield (time_span, RDF.type, E52_TIME_SPAN)
194
+ if self.begin is not None:
195
+ yield (time_span, BEGIN_OF_BEGIN, gyear_literal(self.begin))
196
+ if self.end is not None:
197
+ yield (time_span, END_OF_END, gyear_literal(self.end))