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.
- bookwright/__init__.py +3 -0
- bookwright/__main__.py +6 -0
- bookwright/cli.py +19 -0
- bookwright/commands/__init__.py +0 -0
- bookwright/commands/_envelope.py +36 -0
- bookwright/commands/check.py +75 -0
- bookwright/commands/graph/__init__.py +23 -0
- bookwright/commands/graph/build.py +157 -0
- bookwright/commands/graph/envelope.py +26 -0
- bookwright/commands/graph/query.py +98 -0
- bookwright/commands/init/__init__.py +5 -0
- bookwright/commands/init/conflict.py +107 -0
- bookwright/commands/init/envelope.py +322 -0
- bookwright/commands/init/git.py +96 -0
- bookwright/commands/init/main.py +263 -0
- bookwright/commands/init/resolve.py +193 -0
- bookwright/commands/init/scaffold.py +242 -0
- bookwright/commands/init/validate.py +172 -0
- bookwright/commands/integration/__init__.py +22 -0
- bookwright/commands/integration/use.py +120 -0
- bookwright/commands/validate.py +160 -0
- bookwright/commands/version.py +35 -0
- bookwright/core/__init__.py +35 -0
- bookwright/core/_blocks.py +239 -0
- bookwright/core/_build.py +154 -0
- bookwright/core/_research_block.py +56 -0
- bookwright/core/_translate.py +90 -0
- bookwright/core/errors.py +127 -0
- bookwright/core/iso639_1.py +200 -0
- bookwright/core/manifest.py +343 -0
- bookwright/errors.py +47 -0
- bookwright/golem/__init__.py +71 -0
- bookwright/golem/base.py +200 -0
- bookwright/golem/errors.py +29 -0
- bookwright/golem/modules/__init__.py +1 -0
- bookwright/golem/modules/character.py +109 -0
- bookwright/golem/modules/event.py +91 -0
- bookwright/golem/modules/feature.py +161 -0
- bookwright/golem/modules/inference.py +41 -0
- bookwright/golem/modules/narrative.py +55 -0
- bookwright/golem/modules/provenance.py +197 -0
- bookwright/golem/modules/relationship.py +38 -0
- bookwright/golem/modules/setting.py +30 -0
- bookwright/golem/namespaces.py +332 -0
- bookwright/golem/serialize.py +25 -0
- bookwright/golem/slug.py +22 -0
- bookwright/indexers/__init__.py +47 -0
- bookwright/indexers/base.py +55 -0
- bookwright/indexers/errors.py +80 -0
- bookwright/indexers/rdflib_indexer.py +89 -0
- bookwright/integrations/__init__.py +155 -0
- bookwright/integrations/base.py +117 -0
- bookwright/integrations/claude/__init__.py +29 -0
- bookwright/integrations/constants.py +38 -0
- bookwright/integrations/descriptions.py +48 -0
- bookwright/integrations/errors.py +170 -0
- bookwright/integrations/generic/__init__.py +56 -0
- bookwright/integrations/lint.py +160 -0
- bookwright/integrations/materialize.py +202 -0
- bookwright/integrations/options.py +203 -0
- bookwright/io/__init__.py +1 -0
- bookwright/io/bible.py +500 -0
- bookwright/io/errors.py +98 -0
- bookwright/io/frontmatter.py +61 -0
- bookwright/io/fs.py +226 -0
- bookwright/io/manuscript.py +15 -0
- bookwright/io/project.py +21 -0
- bookwright/io/report.py +107 -0
- bookwright/io/research.py +427 -0
- bookwright/resources/__init__.py +1 -0
- bookwright/resources/commands/bookwright-analyze.md +66 -0
- bookwright/resources/commands/bookwright-bible.md +96 -0
- bookwright/resources/commands/bookwright-checklist.md +67 -0
- bookwright/resources/commands/bookwright-clarify.md +65 -0
- bookwright/resources/commands/bookwright-constitution.md +79 -0
- bookwright/resources/commands/bookwright-continuity.md +70 -0
- bookwright/resources/commands/bookwright-draft.md +74 -0
- bookwright/resources/commands/bookwright-outline.md +71 -0
- bookwright/resources/commands/bookwright-research.md +107 -0
- bookwright/resources/commands/bookwright-scenes.md +66 -0
- bookwright/resources/commands/bookwright-synopsis.md +67 -0
- bookwright/resources/commands/bookwright-verify.md +136 -0
- bookwright/resources/commands/references/golem-character.md +65 -0
- bookwright/resources/commands/references/golem-events-timeline.md +56 -0
- bookwright/resources/commands/references/golem-relationships.md +53 -0
- bookwright/resources/commands/references/greimas-actants.md +57 -0
- bookwright/resources/commands/references/pending-protocol.md +72 -0
- bookwright/resources/commands/references/propp-functions.md +54 -0
- bookwright/resources/commands/references/research-format.md +136 -0
- bookwright/resources/project/.bookwright/cache/.gitkeep +0 -0
- bookwright/resources/project/.bookwright/schema/.gitkeep +0 -0
- bookwright/resources/project/.bookwright/templates/.gitkeep +0 -0
- bookwright/resources/project/.gitignore +23 -0
- bookwright/resources/project/README.md.j2 +40 -0
- bookwright/resources/project/__init__.py +6 -0
- bookwright/resources/project/bible/characters/.gitkeep +0 -0
- bookwright/resources/project/bible/constitution.md.j2 +74 -0
- bookwright/resources/project/bible/glossary.md +36 -0
- bookwright/resources/project/bible/locations/.gitkeep +0 -0
- bookwright/resources/project/bible/pov-structure.md +43 -0
- bookwright/resources/project/bible/relationships.md +36 -0
- bookwright/resources/project/bible/research/_index.md +28 -0
- bookwright/resources/project/bible/research/sources.md +23 -0
- bookwright/resources/project/bible/settings/.gitkeep +0 -0
- bookwright/resources/project/bible/subplots.md +35 -0
- bookwright/resources/project/bible/themes.md +36 -0
- bookwright/resources/project/bible/timeline.md +38 -0
- bookwright/resources/project/manuscript/.gitkeep +0 -0
- bookwright/resources/project/outline/arcs.md +34 -0
- bookwright/resources/project/outline/scenes.md +31 -0
- bookwright/resources/project/outline/structure.md +35 -0
- bookwright/resources/project/outline/synopsis.md +25 -0
- bookwright/resources/schemas/__init__.py +19 -0
- bookwright/resources/schemas/golem-1.1/VERSION +1 -0
- bookwright/resources/schemas/golem-1.1/golem.ttl +1947 -0
- bookwright/resources/schemas/golem-1.1/version.json +8 -0
- bookwright/resources/templates/__init__.py +1 -0
- bookwright/resources/templates/bible/character.md.tmpl +63 -0
- bookwright/resources/templates/bible/location.md.tmpl +37 -0
- bookwright/resources/templates/bible/research/_index.md.tmpl +25 -0
- bookwright/resources/templates/bible/research/sources.md.tmpl +21 -0
- bookwright/resources/templates/bible/research/tema.md.tmpl +37 -0
- bookwright/resources/templates/bible/setting.md.tmpl +38 -0
- bookwright/resources/templates/manifest.template.toml +79 -0
- bookwright/resources/templates/manuscript/chapter.md.tmpl +36 -0
- bookwright/resources/templates/scenes/scene.md.tmpl +37 -0
- bookwright/resources/vocabularies/__init__.py +6 -0
- bookwright/resources/vocabularies/greimas.ttl +4 -0
- bookwright/resources/vocabularies/propp.ttl +4 -0
- bookwright/resources/vocabularies/sources.ttl +82 -0
- bookwright/validation/__init__.py +33 -0
- bookwright/validation/anchor_queries.py +223 -0
- bookwright/validation/base.py +233 -0
- bookwright/validation/queries.py +197 -0
- bookwright/validation/registry.py +185 -0
- bookwright/validation/report.py +106 -0
- bookwright/validation/runner.py +65 -0
- bookwright/validation/validators/__init__.py +9 -0
- bookwright/validation/validators/character_presence.py +202 -0
- bookwright/validation/validators/factual_anchor.py +291 -0
- bookwright/validation/validators/focalization.py +152 -0
- bookwright/validation/validators/setting_continuity.py +100 -0
- bookwright/validation/validators/temporal.py +277 -0
- bookwright_cli-0.2.0.dist-info/METADATA +218 -0
- bookwright_cli-0.2.0.dist-info/RECORD +149 -0
- bookwright_cli-0.2.0.dist-info/WHEEL +4 -0
- bookwright_cli-0.2.0.dist-info/entry_points.txt +2 -0
- bookwright_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- bookwright_cli-0.2.0.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""``factual_anchor`` — structural + chronological audit of research anchors (§ 20.6).
|
|
2
|
+
|
|
3
|
+
The fifth built-in validator. A pure graph consumer (FR-003): it reads the anchor
|
|
4
|
+
sub-graph through :mod:`bookwright.validation.anchor_queries` and the manifest
|
|
5
|
+
through the :class:`ValidationContext`, and emits
|
|
6
|
+
|
|
7
|
+
* **warnings** for structural defects — an unsourced anchor (R1/FR-006), a source
|
|
8
|
+
missing a mandatory provenance facet (R2/FR-007), under-reliable support
|
|
9
|
+
(R3/FR-008), and a missing promoted finding or constrained entity (R4/FR-009);
|
|
10
|
+
* an **error** for a hard anachronism between an anchor's time-span and the
|
|
11
|
+
interval of the event (or timeline) it constrains (R5/FR-010), decided by the
|
|
12
|
+
shared :func:`~bookwright.validation.queries.intervals_disjoint` predicate so the
|
|
13
|
+
contradiction logic is never forked from ``temporal`` (FR-011).
|
|
14
|
+
|
|
15
|
+
It is inert — returns ``[]`` immediately — when ``[research].enabled`` is false
|
|
16
|
+
(FR-015) or the graph carries no anchor (FR-016), so it costs nothing on a
|
|
17
|
+
non-research project. No rdflib here: all SPARQL lives in the projection modules.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import ClassVar
|
|
24
|
+
|
|
25
|
+
from bookwright.golem.namespaces import (
|
|
26
|
+
BW_CONSTRAINS,
|
|
27
|
+
BW_PROMOTES,
|
|
28
|
+
BW_SUPPORTED_BY,
|
|
29
|
+
RELIABILITY_IRI,
|
|
30
|
+
timeline_uri,
|
|
31
|
+
)
|
|
32
|
+
from bookwright.indexers import Indexer
|
|
33
|
+
from bookwright.validation.anchor_queries import (
|
|
34
|
+
FACETS,
|
|
35
|
+
AnchorRecord,
|
|
36
|
+
SourceRecord,
|
|
37
|
+
entity_present,
|
|
38
|
+
load_anchors,
|
|
39
|
+
load_sources_by_anchor,
|
|
40
|
+
)
|
|
41
|
+
from bookwright.validation.base import Severity, ValidationContext, Violation
|
|
42
|
+
from bookwright.validation.queries import (
|
|
43
|
+
EventInterval,
|
|
44
|
+
intervals_disjoint,
|
|
45
|
+
load_intervals,
|
|
46
|
+
resolve_source,
|
|
47
|
+
timeline_bounds,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# The reliability scale, lowest → highest. The rating NAMES are the single
|
|
51
|
+
# vocabulary source (``RELIABILITY_IRI`` keys); only the domain ordering
|
|
52
|
+
# (``baja < media < alta``) lives here. The membership guard below trips if the
|
|
53
|
+
# vocabulary ever gains or renames a rating, so the scale can never silently drift.
|
|
54
|
+
_RELIABILITY_ORDER: tuple[str, ...] = ("baja", "media", "alta")
|
|
55
|
+
_RELIABILITY_RANK: dict[str, int] = {name: rank for rank, name in enumerate(_RELIABILITY_ORDER)}
|
|
56
|
+
assert set(_RELIABILITY_RANK) == set(RELIABILITY_IRI), (
|
|
57
|
+
"reliability scale drifted from RELIABILITY_IRI"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _label(uri: str) -> str:
|
|
62
|
+
"""A short, readable name from a URI (its final path segment)."""
|
|
63
|
+
return uri.rstrip("/").rsplit("/", 1)[-1]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _range(interval: EventInterval) -> str:
|
|
67
|
+
"""A human ``begin-end`` year range, an open bound shown as ``?``."""
|
|
68
|
+
begin = interval.begin if interval.begin is not None else "?"
|
|
69
|
+
end = interval.end if interval.end is not None else "?"
|
|
70
|
+
return f"{begin}-{end}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class _IntervalView:
|
|
75
|
+
"""The run-wide interval data the anachronism rule needs (loaded once).
|
|
76
|
+
|
|
77
|
+
``events`` maps every ``G5_Narrative_Event`` URI to its interval; ``timeline``
|
|
78
|
+
is the overall bounds for a timeline-targeting anchor (``None`` when no anchor
|
|
79
|
+
carries a span, so the data was never loaded).
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
events: dict[str, EventInterval]
|
|
83
|
+
timeline: EventInterval | None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class FactualAnchor:
|
|
87
|
+
"""Audits each research anchor's structural integrity and chronology."""
|
|
88
|
+
|
|
89
|
+
name: ClassVar[str] = "factual_anchor"
|
|
90
|
+
severity_default: ClassVar[Severity] = Severity.warning
|
|
91
|
+
|
|
92
|
+
def validate(self, project: ValidationContext, indexer: Indexer) -> list[Violation]:
|
|
93
|
+
if project.manifest.research.enabled is False:
|
|
94
|
+
return [] # FR-015: the research system is turned off
|
|
95
|
+
anchors = load_anchors(indexer)
|
|
96
|
+
if not anchors:
|
|
97
|
+
return [] # FR-016: nothing to audit on a non-research project
|
|
98
|
+
sources_by_anchor = load_sources_by_anchor(indexer)
|
|
99
|
+
# Interval data is only loaded when at least one anchor carries a time-span —
|
|
100
|
+
# a non-temporal research project pays nothing for the anachronism rule.
|
|
101
|
+
spanned = any(a.span.begin is not None or a.span.end is not None for a in anchors)
|
|
102
|
+
events = load_intervals(indexer) if spanned else {}
|
|
103
|
+
intervals = _IntervalView(
|
|
104
|
+
events=events,
|
|
105
|
+
timeline=timeline_bounds(events) if spanned else None,
|
|
106
|
+
)
|
|
107
|
+
out: list[Violation] = []
|
|
108
|
+
for anchor in anchors: # already sorted by URI (deterministic, FR-003)
|
|
109
|
+
sources = sources_by_anchor.get(anchor.uri, [])
|
|
110
|
+
out.extend(self._audit(anchor, sources, intervals, project, indexer))
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
def _audit(
|
|
114
|
+
self,
|
|
115
|
+
anchor: AnchorRecord,
|
|
116
|
+
sources: list[SourceRecord],
|
|
117
|
+
intervals: _IntervalView,
|
|
118
|
+
project: ValidationContext,
|
|
119
|
+
indexer: Indexer,
|
|
120
|
+
) -> list[Violation]:
|
|
121
|
+
"""Run every rule against one anchor, collecting its violations in order."""
|
|
122
|
+
finding_present = entity_present(indexer, anchor.promotes, project.uri_base)
|
|
123
|
+
out: list[Violation] = []
|
|
124
|
+
out.extend(self._unsourced(anchor, sources, finding_present, indexer))
|
|
125
|
+
out.extend(self._incomplete(anchor, sources, project, indexer))
|
|
126
|
+
out.extend(self._under_reliable(anchor, sources, project, indexer))
|
|
127
|
+
out.extend(self._missing_entity(anchor, finding_present, project, indexer))
|
|
128
|
+
out.extend(self._anachronism(anchor, intervals, project, indexer))
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
def _violation(
|
|
132
|
+
self, anchor: AnchorRecord, indexer: Indexer, message: str, triple: tuple[str, str, str]
|
|
133
|
+
) -> Violation:
|
|
134
|
+
"""A ``warning`` carrying the anchor's locator (``None`` today) + one edge."""
|
|
135
|
+
return Violation(
|
|
136
|
+
validator=self.name,
|
|
137
|
+
severity=Severity.warning,
|
|
138
|
+
message=message,
|
|
139
|
+
source=resolve_source(indexer, anchor.uri),
|
|
140
|
+
triples=(triple,),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# --- R1 unsourced (FR-006) ----------------------------------------------
|
|
144
|
+
|
|
145
|
+
def _unsourced(
|
|
146
|
+
self,
|
|
147
|
+
anchor: AnchorRecord,
|
|
148
|
+
sources: list[SourceRecord],
|
|
149
|
+
finding_present: bool,
|
|
150
|
+
indexer: Indexer,
|
|
151
|
+
) -> list[Violation]:
|
|
152
|
+
# Suppressed when the finding is absent — R4 reports that once (no double-label).
|
|
153
|
+
if not finding_present or sources:
|
|
154
|
+
return []
|
|
155
|
+
message = f"anchor '{_label(anchor.uri)}' promotes a finding with no supporting source"
|
|
156
|
+
triple = (anchor.uri, str(BW_PROMOTES), anchor.promotes)
|
|
157
|
+
return [self._violation(anchor, indexer, message, triple)]
|
|
158
|
+
|
|
159
|
+
# --- R2 provenance-incomplete (FR-007) ----------------------------------
|
|
160
|
+
|
|
161
|
+
def _incomplete(
|
|
162
|
+
self,
|
|
163
|
+
anchor: AnchorRecord,
|
|
164
|
+
sources: list[SourceRecord],
|
|
165
|
+
project: ValidationContext,
|
|
166
|
+
indexer: Indexer,
|
|
167
|
+
) -> list[Violation]:
|
|
168
|
+
book_language = project.manifest.book.language
|
|
169
|
+
out: list[Violation] = []
|
|
170
|
+
for source in sources:
|
|
171
|
+
# The implicated edge is the real finding→source link that locates the
|
|
172
|
+
# source; a missing facet has no object, so it is never a fabricated triple.
|
|
173
|
+
located = (anchor.promotes, str(BW_SUPPORTED_BY), source.uri)
|
|
174
|
+
for facet in FACETS:
|
|
175
|
+
if str(facet.predicate) in source.present_predicates:
|
|
176
|
+
continue
|
|
177
|
+
# ``translation`` is mandatory only for a foreign-language source; if
|
|
178
|
+
# the language itself is unknown it is already flagged, so skip here.
|
|
179
|
+
if facet.foreign_only and (
|
|
180
|
+
source.original_language is None or source.original_language == book_language
|
|
181
|
+
):
|
|
182
|
+
continue
|
|
183
|
+
message = (
|
|
184
|
+
f"source '{_label(source.uri)}' backing anchor '{_label(anchor.uri)}' "
|
|
185
|
+
f"is missing its {facet.label}"
|
|
186
|
+
)
|
|
187
|
+
out.append(self._violation(anchor, indexer, message, located))
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
# --- R3 under-reliable (FR-008) -----------------------------------------
|
|
191
|
+
|
|
192
|
+
def _under_reliable(
|
|
193
|
+
self,
|
|
194
|
+
anchor: AnchorRecord,
|
|
195
|
+
sources: list[SourceRecord],
|
|
196
|
+
project: ValidationContext,
|
|
197
|
+
indexer: Indexer,
|
|
198
|
+
) -> list[Violation]:
|
|
199
|
+
if not sources: # an unsourced anchor is R1's concern, not R3's (no double-label)
|
|
200
|
+
return []
|
|
201
|
+
minimum = project.manifest.research.min_reliability_for_anchor
|
|
202
|
+
rated = [_RELIABILITY_RANK[s.reliability] for s in sources if s.reliability is not None]
|
|
203
|
+
# No rated source at all → below every threshold; else compare the best.
|
|
204
|
+
if rated and max(rated) >= _RELIABILITY_RANK[minimum]:
|
|
205
|
+
return []
|
|
206
|
+
if rated: # a rating is present, it is just too low
|
|
207
|
+
message = (
|
|
208
|
+
f"anchor '{_label(anchor.uri)}' is backed only by sources below the "
|
|
209
|
+
f"minimum reliability '{minimum}'"
|
|
210
|
+
)
|
|
211
|
+
else: # sources exist but none carries a rating at all — not "below", unrated
|
|
212
|
+
message = (
|
|
213
|
+
f"anchor '{_label(anchor.uri)}' is backed by sources but none carries a "
|
|
214
|
+
f"reliability rating (minimum required: '{minimum}')"
|
|
215
|
+
)
|
|
216
|
+
triple = (anchor.uri, str(BW_PROMOTES), anchor.promotes)
|
|
217
|
+
return [self._violation(anchor, indexer, message, triple)]
|
|
218
|
+
|
|
219
|
+
# --- R4 missing entity (FR-009) -----------------------------------------
|
|
220
|
+
|
|
221
|
+
def _missing_entity(
|
|
222
|
+
self,
|
|
223
|
+
anchor: AnchorRecord,
|
|
224
|
+
finding_present: bool,
|
|
225
|
+
project: ValidationContext,
|
|
226
|
+
indexer: Indexer,
|
|
227
|
+
) -> list[Violation]:
|
|
228
|
+
out: list[Violation] = []
|
|
229
|
+
if not finding_present:
|
|
230
|
+
message = f"anchor '{_label(anchor.uri)}' promotes a finding not present in the graph"
|
|
231
|
+
triple = (anchor.uri, str(BW_PROMOTES), anchor.promotes)
|
|
232
|
+
out.append(self._violation(anchor, indexer, message, triple))
|
|
233
|
+
target = anchor.constrains
|
|
234
|
+
target_present = target is not None and entity_present(indexer, target, project.uri_base)
|
|
235
|
+
if not target_present:
|
|
236
|
+
message = (
|
|
237
|
+
f"anchor '{_label(anchor.uri)}' constrains a narrative entity "
|
|
238
|
+
"that is not present in the graph"
|
|
239
|
+
)
|
|
240
|
+
# The dropped-link case has no constrains triple → cite the promotes edge.
|
|
241
|
+
triple = (
|
|
242
|
+
(anchor.uri, str(BW_CONSTRAINS), target)
|
|
243
|
+
if target is not None
|
|
244
|
+
else (anchor.uri, str(BW_PROMOTES), anchor.promotes)
|
|
245
|
+
)
|
|
246
|
+
out.append(self._violation(anchor, indexer, message, triple))
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
# --- R5 anachronism (FR-010/FR-012) -------------------------------------
|
|
250
|
+
|
|
251
|
+
def _anachronism(
|
|
252
|
+
self,
|
|
253
|
+
anchor: AnchorRecord,
|
|
254
|
+
intervals: _IntervalView,
|
|
255
|
+
project: ValidationContext,
|
|
256
|
+
indexer: Indexer,
|
|
257
|
+
) -> list[Violation]:
|
|
258
|
+
span = anchor.span
|
|
259
|
+
if span.begin is None and span.end is None:
|
|
260
|
+
return [] # no time-span → nothing to compare against
|
|
261
|
+
target = anchor.constrains
|
|
262
|
+
if target is None:
|
|
263
|
+
return [] # dropped link — already R4's concern, no interval to compare
|
|
264
|
+
target_interval = self._target_interval(target, intervals, project.uri_base)
|
|
265
|
+
if target_interval is None:
|
|
266
|
+
return [] # non-temporal / non-event target → no comparable interval (FR-012)
|
|
267
|
+
if not intervals_disjoint(span, target_interval):
|
|
268
|
+
return []
|
|
269
|
+
message = (
|
|
270
|
+
f"anchor '{_label(anchor.uri)}' ({_range(span)}) constrains "
|
|
271
|
+
f"'{_label(target)}' ({_range(target_interval)}), but their year ranges "
|
|
272
|
+
"are disjoint (anachronism)"
|
|
273
|
+
)
|
|
274
|
+
return [
|
|
275
|
+
Violation(
|
|
276
|
+
validator=self.name,
|
|
277
|
+
severity=Severity.error,
|
|
278
|
+
message=message,
|
|
279
|
+
source=resolve_source(indexer, anchor.uri),
|
|
280
|
+
triples=((anchor.uri, str(BW_CONSTRAINS), target),),
|
|
281
|
+
)
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
def _target_interval(
|
|
285
|
+
self, target: str, intervals: _IntervalView, uri_base: str
|
|
286
|
+
) -> EventInterval | None:
|
|
287
|
+
"""The interval to compare the span against: timeline bounds, an event's
|
|
288
|
+
interval, or ``None`` for a non-temporal / non-event target (D3)."""
|
|
289
|
+
if target == str(timeline_uri(uri_base)):
|
|
290
|
+
return intervals.timeline
|
|
291
|
+
return intervals.events.get(target)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""``focalization`` — prose vs. the declared narrative voice (FR-018, research D5).
|
|
2
|
+
|
|
3
|
+
Reads the constitution's narrative-voice declaration (Spanish "Voz narrativa" or
|
|
4
|
+
English "Narrative voice", case-insensitive) for the declared grammatical person and,
|
|
5
|
+
if a bible character is named there, the focal character. Then flags two heuristic
|
|
6
|
+
breaks, LLM-free, defaulting to ``warning``:
|
|
7
|
+
|
|
8
|
+
* first-person pronouns outside dialogue when **third person** is declared,
|
|
9
|
+
* interiority verbs attached to a **non-focal** bible character (head-hopping) under
|
|
10
|
+
**third-person-limited**.
|
|
11
|
+
|
|
12
|
+
No parsable declaration → zero findings (edge case).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import ClassVar
|
|
20
|
+
|
|
21
|
+
from bookwright.indexers import Indexer
|
|
22
|
+
from bookwright.validation.base import Severity, ValidationContext, Violation
|
|
23
|
+
|
|
24
|
+
_DECLARATION = re.compile(r"(?im)^\s*(?:voz narrativa|narrative voice)\s*:\s*(?P<body>.+)$")
|
|
25
|
+
_THIRD = re.compile(r"(?i)\b(tercera|third)\b")
|
|
26
|
+
_FIRST = re.compile(r"(?i)\b(primera|first)\b")
|
|
27
|
+
_LIMITED = re.compile(r"(?i)\b(limitada|limitado|limited)\b")
|
|
28
|
+
|
|
29
|
+
# First-person markers we treat as a voice break outside dialogue (conservative).
|
|
30
|
+
_FIRST_PERSON = re.compile(r"(?i)(?<![\wáéíóúñ])(yo|nosotros|nosotras|i|we)(?![\wáéíóúñ])")
|
|
31
|
+
# Lines that are dialogue (Spanish em-dash openers or quotation marks) are exempt.
|
|
32
|
+
# Spanish typography (en/em dashes, angle + curly quotes) is intentional here.
|
|
33
|
+
_DIALOGUE_PREFIX = ("—", "–", "-", '"', "«", "“", "'", "‘") # noqa: RUF001
|
|
34
|
+
# Interiority verbs — third-person reports of a character's inner life.
|
|
35
|
+
_INTERIORITY = re.compile(
|
|
36
|
+
r"(?i)(?<![\wáéíóúñ])"
|
|
37
|
+
r"(pensó|sintió|supo|recordó|creyó|temió|imaginó|comprendió|deseó|"
|
|
38
|
+
r"thought|felt|knew|remembered|believed|feared|wondered|realized|realised|wished)"
|
|
39
|
+
r"(?![\wáéíóúñ])"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class _Declaration:
|
|
45
|
+
person: str | None # "first" | "third" | None
|
|
46
|
+
limited: bool
|
|
47
|
+
focal: str | None # bible character named in the declaration, if any
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Focalization:
|
|
51
|
+
"""Flags prose that breaks the declared narrative person / focal character."""
|
|
52
|
+
|
|
53
|
+
name: ClassVar[str] = "focalization"
|
|
54
|
+
severity_default: ClassVar[Severity] = Severity.warning
|
|
55
|
+
|
|
56
|
+
def validate(self, project: ValidationContext, indexer: Indexer) -> list[Violation]:
|
|
57
|
+
constitution = project.constitution_text()
|
|
58
|
+
if constitution is None:
|
|
59
|
+
return []
|
|
60
|
+
character_names = [name for name, _ in project.character_names()]
|
|
61
|
+
declaration = _parse_declaration(constitution, character_names)
|
|
62
|
+
if declaration is None or declaration.person is None:
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
files = project.manuscript_files()
|
|
66
|
+
out: list[Violation] = []
|
|
67
|
+
if declaration.person == "third":
|
|
68
|
+
out.extend(self._first_person_breaks(files))
|
|
69
|
+
if declaration.limited:
|
|
70
|
+
out.extend(self._head_hopping(files, character_names, declaration.focal))
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
def _first_person_breaks(self, files: tuple[tuple[str, str], ...]) -> list[Violation]:
|
|
74
|
+
out: list[Violation] = []
|
|
75
|
+
for relpath, text in files:
|
|
76
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
77
|
+
if _is_dialogue(line):
|
|
78
|
+
continue
|
|
79
|
+
match = _FIRST_PERSON.search(line)
|
|
80
|
+
if match:
|
|
81
|
+
out.append(
|
|
82
|
+
Violation(
|
|
83
|
+
validator=self.name,
|
|
84
|
+
severity=Severity.warning,
|
|
85
|
+
message=(
|
|
86
|
+
f"first-person marker '{match.group(1)}' outside dialogue, but "
|
|
87
|
+
"the constitution declares a third-person narrative voice"
|
|
88
|
+
),
|
|
89
|
+
source=f"{relpath}:{lineno}",
|
|
90
|
+
triples=(),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
break # one finding per file (citing the first break)
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
def _head_hopping(
|
|
97
|
+
self,
|
|
98
|
+
files: tuple[tuple[str, str], ...],
|
|
99
|
+
character_names: list[str],
|
|
100
|
+
focal: str | None,
|
|
101
|
+
) -> list[Violation]:
|
|
102
|
+
non_focal = sorted(n for n in character_names if n != focal)
|
|
103
|
+
seen: set[str] = set()
|
|
104
|
+
out: list[Violation] = []
|
|
105
|
+
for relpath, text in files:
|
|
106
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
107
|
+
if not _INTERIORITY.search(line):
|
|
108
|
+
continue
|
|
109
|
+
for name in non_focal:
|
|
110
|
+
if name in seen:
|
|
111
|
+
continue
|
|
112
|
+
if re.search(rf"\b{re.escape(name)}\b", line):
|
|
113
|
+
seen.add(name)
|
|
114
|
+
out.append(
|
|
115
|
+
Violation(
|
|
116
|
+
validator=self.name,
|
|
117
|
+
severity=Severity.warning,
|
|
118
|
+
message=(
|
|
119
|
+
f"interiority attributed to '{name}', a non-focal character, "
|
|
120
|
+
"under a third-person-limited narrative voice (head-hopping)"
|
|
121
|
+
),
|
|
122
|
+
source=f"{relpath}:{lineno}",
|
|
123
|
+
triples=(),
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_declaration(text: str, character_names: list[str]) -> _Declaration | None:
|
|
130
|
+
match = _DECLARATION.search(text)
|
|
131
|
+
if match is None:
|
|
132
|
+
return None
|
|
133
|
+
body = match.group("body")
|
|
134
|
+
person: str | None = None
|
|
135
|
+
if _THIRD.search(body):
|
|
136
|
+
person = "third"
|
|
137
|
+
elif _FIRST.search(body):
|
|
138
|
+
person = "first"
|
|
139
|
+
focal = next(
|
|
140
|
+
(
|
|
141
|
+
name
|
|
142
|
+
for name in sorted(character_names, key=len, reverse=True)
|
|
143
|
+
if re.search(rf"\b{re.escape(name)}\b", body)
|
|
144
|
+
),
|
|
145
|
+
None,
|
|
146
|
+
)
|
|
147
|
+
return _Declaration(person=person, limited=bool(_LIMITED.search(body)), focal=focal)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_dialogue(line: str) -> bool:
|
|
151
|
+
stripped = line.lstrip()
|
|
152
|
+
return stripped.startswith(_DIALOGUE_PREFIX)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""``setting_continuity`` — contradicting descriptors for one setting (FR-017, D4).
|
|
2
|
+
|
|
3
|
+
A small built-in **contradiction lexicon** of antonym groups (e.g. *coastal* /
|
|
4
|
+
*inland*). When the same setting is described with two terms from one group in
|
|
5
|
+
**different files**, that is a continuity warning citing both locations. Heuristic
|
|
6
|
+
and LLM-free, so it defaults to ``warning`` — it never gates CI.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import ClassVar
|
|
13
|
+
|
|
14
|
+
from bookwright.indexers import Indexer
|
|
15
|
+
from bookwright.validation.base import Severity, ValidationContext, Violation
|
|
16
|
+
|
|
17
|
+
# Antonym groups: two terms from one group on one setting, in different files, clash.
|
|
18
|
+
_LEXICON: tuple[tuple[str, ...], ...] = (
|
|
19
|
+
("coastal", "inland"),
|
|
20
|
+
("costera", "costero", "interior"),
|
|
21
|
+
("urban", "rural"),
|
|
22
|
+
("urbana", "urbano", "rural"),
|
|
23
|
+
("mountainous", "flat"),
|
|
24
|
+
("montañosa", "montañoso", "llana", "llano"),
|
|
25
|
+
("tropical", "arctic"),
|
|
26
|
+
("desert", "forest"),
|
|
27
|
+
("desierto", "bosque"),
|
|
28
|
+
)
|
|
29
|
+
# Word-boundary matcher per lexicon term, compiled once at import (not per line).
|
|
30
|
+
_TERM_PATTERNS: dict[str, re.Pattern[str]] = {
|
|
31
|
+
term: re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE)
|
|
32
|
+
for group in _LEXICON
|
|
33
|
+
for term in group
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SettingContinuity:
|
|
38
|
+
"""Flags a setting tagged with mutually-exclusive descriptors across files."""
|
|
39
|
+
|
|
40
|
+
name: ClassVar[str] = "setting_continuity"
|
|
41
|
+
severity_default: ClassVar[Severity] = Severity.warning
|
|
42
|
+
|
|
43
|
+
def validate(self, project: ValidationContext, indexer: Indexer) -> list[Violation]:
|
|
44
|
+
files = project.manuscript_files()
|
|
45
|
+
out: list[Violation] = []
|
|
46
|
+
for setting_name, _ in project.setting_names():
|
|
47
|
+
out.extend(self._check_setting(setting_name, files))
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
def _check_setting(
|
|
51
|
+
self, setting_name: str, files: tuple[tuple[str, str], ...]
|
|
52
|
+
) -> list[Violation]:
|
|
53
|
+
name_re = re.compile(rf"\b{re.escape(setting_name)}\b", re.IGNORECASE)
|
|
54
|
+
# term → (relpath, line) of its first occurrence in a file mentioning the setting.
|
|
55
|
+
occurrences: dict[str, tuple[str, int]] = {}
|
|
56
|
+
for relpath, text in files:
|
|
57
|
+
if not name_re.search(text):
|
|
58
|
+
continue
|
|
59
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
60
|
+
for term, pattern in _TERM_PATTERNS.items():
|
|
61
|
+
if term in occurrences:
|
|
62
|
+
continue
|
|
63
|
+
if pattern.search(line):
|
|
64
|
+
occurrences[term] = (relpath, lineno)
|
|
65
|
+
|
|
66
|
+
out: list[Violation] = []
|
|
67
|
+
for group in _LEXICON:
|
|
68
|
+
present = [term for term in group if term in occurrences]
|
|
69
|
+
clash = _first_cross_file_pair(present, occurrences)
|
|
70
|
+
if clash is None:
|
|
71
|
+
continue
|
|
72
|
+
(term_a, loc_a), (term_b, loc_b) = clash
|
|
73
|
+
src_a, line_a = loc_a
|
|
74
|
+
src_b, line_b = loc_b
|
|
75
|
+
out.append(
|
|
76
|
+
Violation(
|
|
77
|
+
validator=self.name,
|
|
78
|
+
severity=Severity.warning,
|
|
79
|
+
message=(
|
|
80
|
+
f"setting '{setting_name}' is described as '{term_a}' "
|
|
81
|
+
f"({src_a}:{line_a}) and '{term_b}' ({src_b}:{line_b}) — "
|
|
82
|
+
"contradicting descriptors across files"
|
|
83
|
+
),
|
|
84
|
+
source=f"{src_a}:{line_a}",
|
|
85
|
+
triples=(),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _first_cross_file_pair(
|
|
92
|
+
present: list[str], occurrences: dict[str, tuple[str, int]]
|
|
93
|
+
) -> tuple[tuple[str, tuple[str, int]], tuple[str, tuple[str, int]]] | None:
|
|
94
|
+
"""The first pair of present terms whose recorded files differ (sorted, D8)."""
|
|
95
|
+
for i in range(len(present)):
|
|
96
|
+
for j in range(i + 1, len(present)):
|
|
97
|
+
a, b = present[i], present[j]
|
|
98
|
+
if occurrences[a][0] != occurrences[b][0]:
|
|
99
|
+
return (a, occurrences[a]), (b, occurrences[b])
|
|
100
|
+
return None
|