paces 0.0.2__tar.gz → 0.0.3__tar.gz
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.
- {paces-0.0.2 → paces-0.0.3}/PKG-INFO +1 -1
- {paces-0.0.2 → paces-0.0.3}/paces/__init__.py +4 -0
- paces-0.0.3/paces/edits.py +452 -0
- {paces-0.0.2 → paces-0.0.3}/paces/model.py +6 -1
- {paces-0.0.2 → paces-0.0.3}/paces/segmenters.py +204 -8
- {paces-0.0.2 → paces-0.0.3}/paces/tools.py +59 -4
- {paces-0.0.2 → paces-0.0.3}/pyproject.toml +1 -1
- paces-0.0.3/tests/test_chapters.py +208 -0
- paces-0.0.3/tests/test_edits.py +455 -0
- {paces-0.0.2 → paces-0.0.3}/.gitignore +0 -0
- {paces-0.0.2 → paces-0.0.3}/LICENSE +0 -0
- {paces-0.0.2 → paces-0.0.3}/README.md +0 -0
- {paces-0.0.2 → paces-0.0.3}/docs/README.md +0 -0
- {paces-0.0.2 → paces-0.0.3}/docs/alignment/README.md +0 -0
- {paces-0.0.2 → paces-0.0.3}/docs/poc-reference/README.md +0 -0
- {paces-0.0.2 → paces-0.0.3}/docs/poc-reference/artifacts/clips.json +0 -0
- {paces-0.0.2 → paces-0.0.3}/paces/__main__.py +0 -0
- {paces-0.0.2 → paces-0.0.3}/paces/projection.py +0 -0
- {paces-0.0.2 → paces-0.0.3}/paces/render.py +0 -0
- {paces-0.0.2 → paces-0.0.3}/tests/data/routine.json +0 -0
- {paces-0.0.2 → paces-0.0.3}/tests/poc_fixture.py +0 -0
- {paces-0.0.2 → paces-0.0.3}/tests/test_roundtrip_poc.py +0 -0
- {paces-0.0.2 → paces-0.0.3}/tests/test_segment.py +0 -0
- {paces-0.0.2 → paces-0.0.3}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: paces
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: Turn instructional media into structured, interactive learning material
|
|
5
5
|
Project-URL: Homepage, https://github.com/thorwhalen/paces
|
|
6
6
|
Project-URL: Repository, https://github.com/thorwhalen/paces
|
|
@@ -48,6 +48,7 @@ from paces.model import (
|
|
|
48
48
|
seconds_per_unit,
|
|
49
49
|
validate_document,
|
|
50
50
|
)
|
|
51
|
+
from paces.edits import apply_edits, merge_regenerated
|
|
51
52
|
from paces.projection import to_document
|
|
52
53
|
from paces.render import render_html
|
|
53
54
|
from paces.segmenters import (
|
|
@@ -87,6 +88,9 @@ __all__ = [
|
|
|
87
88
|
"register",
|
|
88
89
|
"capabilities",
|
|
89
90
|
"to_document",
|
|
91
|
+
# editing & regeneration
|
|
92
|
+
"apply_edits",
|
|
93
|
+
"merge_regenerated",
|
|
90
94
|
# rendering
|
|
91
95
|
"render_html",
|
|
92
96
|
]
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Document-layer edit protection: typed patches write Locks; regeneration
|
|
2
|
+
merges without eating edits.
|
|
3
|
+
|
|
4
|
+
The POC's single most expensive failure was regeneration destroying hand
|
|
5
|
+
edits (``docs/07-annotation-model.md §1.3``). The remedy has two halves, both
|
|
6
|
+
here and both store-independent (``docs/07 §6.4`` steps 4–6):
|
|
7
|
+
|
|
8
|
+
- :func:`apply_edits` — edits are **typed patches**
|
|
9
|
+
(``{"op": "set", "path": "/steps/b4/name", "value": ...}``), validated
|
|
10
|
+
before application, and every edit writes a :class:`~paces.model.Lock` with
|
|
11
|
+
the pre-edit value, so every edit is reversible and every protected path is
|
|
12
|
+
explicit.
|
|
13
|
+
- :func:`merge_regenerated` — a fresh analysis projection merged against the
|
|
14
|
+
committed document: locked paths keep the committed value, everything else
|
|
15
|
+
takes the fresh value, and committed-only steps that carry protection
|
|
16
|
+
survive.
|
|
17
|
+
|
|
18
|
+
Path rules (each earned by an adversarial review, PR #11):
|
|
19
|
+
|
|
20
|
+
- List items are addressed **by id first** (``/steps/b4/name``); an ASCII
|
|
21
|
+
digit segment is an index only when no item carries that id. Ids are
|
|
22
|
+
stabler across regenerations — always prefer them.
|
|
23
|
+
- Field segments accept the wire's camelCase or Python's snake_case; recorded
|
|
24
|
+
``Lock.path``\\ s are canonicalised to snake_case and to id-form where an
|
|
25
|
+
unambiguous id exists.
|
|
26
|
+
- On merge, a lock is re-applied by **matching list items structurally**
|
|
27
|
+
(id; a span's exact ``(source, role, start)``, falling back to
|
|
28
|
+
``(source, role)`` only for singleton groups; an artifact's ``uri``) —
|
|
29
|
+
never by bare position, not even within a group, because regeneration
|
|
30
|
+
reorders lists and a positional write would land on the wrong item. When
|
|
31
|
+
no confident match exists, nothing is written and the lock survives as
|
|
32
|
+
the record. Scalar list items (tags) have no identity besides their
|
|
33
|
+
value, so an edited scalar cannot be re-found after regeneration — the
|
|
34
|
+
edit does not survive; only its lock record does.
|
|
35
|
+
- ``attrs`` bags merge committed-over-fresh per key: they are user/renderer
|
|
36
|
+
data that analysis does not produce, so regeneration never wins there.
|
|
37
|
+
|
|
38
|
+
Not yet recorded anywhere: the fresh values a merge *rejects*
|
|
39
|
+
(``Origin.value_digest`` and the op-log arrive with the evidence layer,
|
|
40
|
+
issue #4).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import copy
|
|
46
|
+
import re
|
|
47
|
+
from collections.abc import Mapping, Sequence
|
|
48
|
+
from datetime import datetime, timezone
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from paces.model import Lock, StepDocument
|
|
52
|
+
|
|
53
|
+
#: The ops v1 supports. ``append``/``delete`` arrive at the third real need
|
|
54
|
+
#: (they require tombstone semantics in the merge — see issue #5).
|
|
55
|
+
SUPPORTED_OPS = ("set",)
|
|
56
|
+
|
|
57
|
+
_CAMEL_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
|
|
58
|
+
|
|
59
|
+
#: Structural identity keys for list items that carry no ``id``.
|
|
60
|
+
_SPAN_KEYS = frozenset({"source", "role", "start"})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _now_iso() -> str:
|
|
64
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _snake(key: str) -> str:
|
|
68
|
+
return _CAMEL_BOUNDARY.sub("_", key).lower()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _split_path(path: str) -> list[str]:
|
|
72
|
+
if not isinstance(path, str) or not path.startswith("/") or path == "/":
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"path must be a JSON-pointer-style path like '/steps/b4/name'; "
|
|
75
|
+
f"got {path!r}"
|
|
76
|
+
)
|
|
77
|
+
return path[1:].split("/")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_index(segment: str) -> bool:
|
|
81
|
+
return segment.isascii() and segment.isdigit()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _index_of(items: list, segment: str, *, at: str) -> int:
|
|
85
|
+
"""Resolve a list segment: an item id first; an ASCII digit index second.
|
|
86
|
+
|
|
87
|
+
Id-first because ids are the stable address — and because a segmenter can
|
|
88
|
+
legitimately mint all-digit ids (chapters titled "1", "2", ...), which
|
|
89
|
+
index-first would silently misroute.
|
|
90
|
+
"""
|
|
91
|
+
for i, item in enumerate(items):
|
|
92
|
+
if isinstance(item, Mapping) and item.get("id") == segment:
|
|
93
|
+
return i
|
|
94
|
+
if _is_index(segment):
|
|
95
|
+
index = int(segment)
|
|
96
|
+
if index < len(items):
|
|
97
|
+
return index
|
|
98
|
+
raise ValueError(f"{at}: index {index} out of range (len {len(items)})")
|
|
99
|
+
ids = [item.get("id") for item in items if isinstance(item, Mapping)]
|
|
100
|
+
raise ValueError(f"{at}: no item with id {segment!r} (have: {ids})")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _key_of(container: Mapping, segment: str, *, at: str) -> str:
|
|
104
|
+
"""Resolve a field segment; accepts wire camelCase, returns snake_case."""
|
|
105
|
+
if segment in container:
|
|
106
|
+
return segment
|
|
107
|
+
snake = _snake(segment)
|
|
108
|
+
if snake in container:
|
|
109
|
+
return snake
|
|
110
|
+
raise ValueError(f"{at}: no field {segment!r} (have: {sorted(container)})")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_parent(root: Any, segments: list[str], *, path: str):
|
|
114
|
+
"""The parent container of the leaf, and the resolved leaf key."""
|
|
115
|
+
node = root
|
|
116
|
+
for i, segment in enumerate(segments[:-1]):
|
|
117
|
+
at = "/" + "/".join(segments[: i + 1])
|
|
118
|
+
if isinstance(node, list):
|
|
119
|
+
node = node[_index_of(node, segment, at=at)]
|
|
120
|
+
elif isinstance(node, Mapping):
|
|
121
|
+
node = node[_key_of(node, segment, at=at)]
|
|
122
|
+
else:
|
|
123
|
+
raise ValueError(f"{at}: cannot descend into {type(node).__name__}")
|
|
124
|
+
leaf = segments[-1]
|
|
125
|
+
if isinstance(node, list):
|
|
126
|
+
return node, _index_of(node, leaf, at=path)
|
|
127
|
+
if isinstance(node, Mapping):
|
|
128
|
+
return node, _key_of(node, leaf, at=path)
|
|
129
|
+
raise ValueError(f"{path}: cannot set into {type(node).__name__}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _canonical_segments(root: Any, segments: list[str], *, path: str) -> list[str]:
|
|
133
|
+
"""The stablest spelling of a path: snake_case fields; list items by id
|
|
134
|
+
when one exists unambiguously in that list, by index otherwise."""
|
|
135
|
+
node, out = root, []
|
|
136
|
+
for i, segment in enumerate(segments):
|
|
137
|
+
at = "/" + "/".join(segments[: i + 1])
|
|
138
|
+
if isinstance(node, list):
|
|
139
|
+
index = _index_of(node, segment, at=at)
|
|
140
|
+
item = node[index]
|
|
141
|
+
item_id = item.get("id") if isinstance(item, Mapping) else None
|
|
142
|
+
ids = [x.get("id") for x in node if isinstance(x, Mapping)]
|
|
143
|
+
unambiguous = item_id is not None and ids.count(item_id) == 1
|
|
144
|
+
out.append(str(item_id) if unambiguous else str(index))
|
|
145
|
+
node = item
|
|
146
|
+
elif isinstance(node, Mapping):
|
|
147
|
+
key = _key_of(node, segment, at=at)
|
|
148
|
+
out.append(key)
|
|
149
|
+
node = node[key]
|
|
150
|
+
else:
|
|
151
|
+
raise ValueError(f"{at}: cannot descend into {type(node).__name__}")
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _lock_site(dump: dict, segments: list[str]) -> tuple[dict, str]:
|
|
156
|
+
"""The node that records the lock, and the lock path relative to it.
|
|
157
|
+
|
|
158
|
+
Structural, not heuristic: only paths of the form
|
|
159
|
+
``steps/<item>(/steps/<item>)*/<field>...`` are owned by the innermost
|
|
160
|
+
step; everything else — ``attrs`` contents included, whatever shape the
|
|
161
|
+
user's data takes — locks on the document itself.
|
|
162
|
+
"""
|
|
163
|
+
site, node, depth, i = dump, dump, 0, 0
|
|
164
|
+
while i + 2 <= len(segments) - 1 and segments[i] == "steps":
|
|
165
|
+
items = node["steps"]
|
|
166
|
+
at = "/" + "/".join(segments[: i + 2])
|
|
167
|
+
node = items[_index_of(items, segments[i + 1], at=at)]
|
|
168
|
+
site, depth = node, i + 2
|
|
169
|
+
i += 2
|
|
170
|
+
return site, "/" + "/".join(segments[depth:])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def apply_edits(
|
|
174
|
+
doc: StepDocument,
|
|
175
|
+
edits: Mapping[str, Any] | Sequence[Mapping[str, Any]],
|
|
176
|
+
*,
|
|
177
|
+
by: str,
|
|
178
|
+
at: str | None = None,
|
|
179
|
+
reason: str | None = None,
|
|
180
|
+
) -> StepDocument:
|
|
181
|
+
"""Apply typed patches to a document, recording a Lock per edit.
|
|
182
|
+
|
|
183
|
+
Each edit is ``{"op": "set", "path": ..., "value": ...}`` (a single edit
|
|
184
|
+
may be passed bare). All edits are validated together — an invalid edit
|
|
185
|
+
means NO edit is applied. Editing an already-locked path replaces the
|
|
186
|
+
lock (``was`` becomes the value this edit overwrote, keeping the last
|
|
187
|
+
edit reversible).
|
|
188
|
+
|
|
189
|
+
>>> from paces.model import Measure, Step, StepDocument
|
|
190
|
+
>>> doc = StepDocument(id='g', title='G', steps=[
|
|
191
|
+
... Step(id='a', name='old', duration=Measure(value='2', unit='eight'))])
|
|
192
|
+
>>> edited = apply_edits(doc, [{'op': 'set', 'path': '/steps/a/name',
|
|
193
|
+
... 'value': 'new'}], by='user:thor')
|
|
194
|
+
>>> edited.steps[0].name, edited.steps[0].locks[0].was
|
|
195
|
+
('new', 'old')
|
|
196
|
+
"""
|
|
197
|
+
if isinstance(edits, Mapping):
|
|
198
|
+
edits = [edits]
|
|
199
|
+
timestamp = at or _now_iso()
|
|
200
|
+
dump = doc.model_dump(mode="python", by_alias=False)
|
|
201
|
+
for i, edit in enumerate(edits):
|
|
202
|
+
if not isinstance(edit, Mapping):
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"edits[{i}]: expected a mapping like "
|
|
205
|
+
f"{{'op': 'set', 'path': ..., 'value': ...}}; "
|
|
206
|
+
f"got {type(edit).__name__}"
|
|
207
|
+
)
|
|
208
|
+
op = edit.get("op")
|
|
209
|
+
if op not in SUPPORTED_OPS:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"edits[{i}]: unsupported op {op!r} (v1 supports: "
|
|
212
|
+
f"{', '.join(SUPPORTED_OPS)})"
|
|
213
|
+
)
|
|
214
|
+
raw_path = edit.get("path", "")
|
|
215
|
+
segments = _canonical_segments(dump, _split_path(raw_path), path=raw_path)
|
|
216
|
+
container, key = _resolve_parent(dump, segments, path=raw_path)
|
|
217
|
+
# The lock site must resolve BEFORE the mutation: an edit may change
|
|
218
|
+
# the very value a segment addresses (renaming a step's id).
|
|
219
|
+
site, relative = _lock_site(dump, segments)
|
|
220
|
+
was = copy.deepcopy(container[key])
|
|
221
|
+
container[key] = copy.deepcopy(edit["value"])
|
|
222
|
+
locks = site.setdefault("locks", [])
|
|
223
|
+
locks[:] = [lock for lock in locks if lock.get("path") != relative]
|
|
224
|
+
locks.append(
|
|
225
|
+
Lock(path=relative, by=by, at=timestamp, was=was, reason=reason).model_dump(
|
|
226
|
+
mode="python"
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
return StepDocument.model_validate(dump)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ── merge: locked values win, matched structurally ──────────────────────────
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _match_index(citems: list, cidx: int, fitems: list) -> int | None:
|
|
236
|
+
"""The fresh index corresponding to committed item *cidx* — never bare
|
|
237
|
+
position on a reorderable list. ``None`` means no confident match."""
|
|
238
|
+
citem = citems[cidx]
|
|
239
|
+
if isinstance(citem, Mapping):
|
|
240
|
+
cid = citem.get("id")
|
|
241
|
+
if cid is not None:
|
|
242
|
+
for j, fitem in enumerate(fitems):
|
|
243
|
+
if isinstance(fitem, Mapping) and fitem.get("id") == cid:
|
|
244
|
+
return j
|
|
245
|
+
return None
|
|
246
|
+
if _SPAN_KEYS <= set(citem): # a SourceSpan
|
|
247
|
+
# Anchor on the exact (source, role, start) triple first — a
|
|
248
|
+
# reorder keeps starts, so this survives insertion AND intra-group
|
|
249
|
+
# shuffling. Only when that fails, fall back to (source, role),
|
|
250
|
+
# and ONLY when that group is a singleton on both sides: an
|
|
251
|
+
# ordinal within a group is bare position wearing a costume
|
|
252
|
+
# (adversarial re-review of PR #11). Anything else declines.
|
|
253
|
+
def _triple(item):
|
|
254
|
+
return (item.get("source"), item.get("role"), item.get("start"))
|
|
255
|
+
|
|
256
|
+
def _pair(item):
|
|
257
|
+
return (item.get("source"), item.get("role"))
|
|
258
|
+
|
|
259
|
+
triple_hits = [
|
|
260
|
+
j
|
|
261
|
+
for j, fitem in enumerate(fitems)
|
|
262
|
+
if isinstance(fitem, Mapping) and _triple(fitem) == _triple(citem)
|
|
263
|
+
]
|
|
264
|
+
if len(triple_hits) == 1:
|
|
265
|
+
return triple_hits[0]
|
|
266
|
+
committed_group = [
|
|
267
|
+
x for x in citems if isinstance(x, Mapping) and _pair(x) == _pair(citem)
|
|
268
|
+
]
|
|
269
|
+
fresh_group = [
|
|
270
|
+
j
|
|
271
|
+
for j, fitem in enumerate(fitems)
|
|
272
|
+
if isinstance(fitem, Mapping) and _pair(fitem) == _pair(citem)
|
|
273
|
+
]
|
|
274
|
+
if len(committed_group) == 1 and len(fresh_group) == 1:
|
|
275
|
+
return fresh_group[0]
|
|
276
|
+
return None
|
|
277
|
+
if "uri" in citem: # an ArtifactRef: the uri is its identity
|
|
278
|
+
for j, fitem in enumerate(fitems):
|
|
279
|
+
if isinstance(fitem, Mapping) and fitem.get("uri") == citem["uri"]:
|
|
280
|
+
return j
|
|
281
|
+
return None
|
|
282
|
+
return None
|
|
283
|
+
for j, fitem in enumerate(fitems): # scalar: first equal value
|
|
284
|
+
if fitem == citem:
|
|
285
|
+
return j
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _apply_lock(fresh: Any, committed: Any, path: str) -> bool:
|
|
290
|
+
"""Re-apply the committed value at *path* onto *fresh*.
|
|
291
|
+
|
|
292
|
+
Walks both structures in parallel, matching list items structurally.
|
|
293
|
+
Returns False — writing nothing — when the path cannot be confidently
|
|
294
|
+
resolved in the fresh structure; the lock then survives as the record.
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
segments = _split_path(path)
|
|
298
|
+
cnode, fnode = committed, fresh
|
|
299
|
+
for i, segment in enumerate(segments[:-1]):
|
|
300
|
+
at = "/" + "/".join(segments[: i + 1])
|
|
301
|
+
if isinstance(cnode, list):
|
|
302
|
+
if not isinstance(fnode, list):
|
|
303
|
+
return False
|
|
304
|
+
cidx = _index_of(cnode, segment, at=at)
|
|
305
|
+
fidx = _match_index(cnode, cidx, fnode)
|
|
306
|
+
if fidx is None:
|
|
307
|
+
return False
|
|
308
|
+
cnode, fnode = cnode[cidx], fnode[fidx]
|
|
309
|
+
elif isinstance(cnode, Mapping):
|
|
310
|
+
key = _key_of(cnode, segment, at=at)
|
|
311
|
+
if not isinstance(fnode, Mapping) or key not in fnode:
|
|
312
|
+
return False
|
|
313
|
+
cnode, fnode = cnode[key], fnode[key]
|
|
314
|
+
else:
|
|
315
|
+
return False
|
|
316
|
+
leaf = segments[-1]
|
|
317
|
+
if isinstance(cnode, list):
|
|
318
|
+
if not isinstance(fnode, list):
|
|
319
|
+
return False
|
|
320
|
+
cidx = _index_of(cnode, leaf, at=path)
|
|
321
|
+
fidx = _match_index(cnode, cidx, fnode)
|
|
322
|
+
if fidx is None:
|
|
323
|
+
return False
|
|
324
|
+
fnode[fidx] = copy.deepcopy(cnode[cidx])
|
|
325
|
+
return True
|
|
326
|
+
if isinstance(cnode, Mapping):
|
|
327
|
+
key = _key_of(cnode, leaf, at=path)
|
|
328
|
+
if not isinstance(fnode, Mapping) or key not in fnode:
|
|
329
|
+
return False
|
|
330
|
+
fnode[key] = copy.deepcopy(cnode[key])
|
|
331
|
+
return True
|
|
332
|
+
return False
|
|
333
|
+
except ValueError:
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _value_at(dump: Mapping, path: str) -> Any:
|
|
338
|
+
segments = _split_path(path)
|
|
339
|
+
container, key = _resolve_parent(dict(dump), segments, path=path)
|
|
340
|
+
return copy.deepcopy(container[key])
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _is_protected(step_dump: Mapping) -> bool:
|
|
344
|
+
origin = step_dump.get("origin") or {}
|
|
345
|
+
generated_by = origin.get("generated_by") or ""
|
|
346
|
+
return bool(step_dump.get("locks")) or generated_by.startswith("user:")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _merged_attrs(committed: Mapping, fresh: Mapping) -> dict:
|
|
350
|
+
"""attrs are user/renderer data analysis does not produce: committed wins
|
|
351
|
+
per key, fresh-only keys are kept."""
|
|
352
|
+
return {**copy.deepcopy(dict(fresh)), **copy.deepcopy(dict(committed))}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _merge_steps(committed: list[dict], fresh: list[dict]) -> list[dict]:
|
|
356
|
+
committed_by_id: dict[str, dict] = {}
|
|
357
|
+
for step in committed: # first occurrence wins, matching apply_edits
|
|
358
|
+
committed_by_id.setdefault(step["id"], step)
|
|
359
|
+
fresh_ids = {step["id"] for step in fresh}
|
|
360
|
+
merged: list[dict] = []
|
|
361
|
+
for fresh_step in fresh:
|
|
362
|
+
committed_step = committed_by_id.get(fresh_step["id"])
|
|
363
|
+
if committed_step is None:
|
|
364
|
+
merged.append(copy.deepcopy(fresh_step))
|
|
365
|
+
continue
|
|
366
|
+
out = copy.deepcopy(fresh_step)
|
|
367
|
+
out["steps"] = _merge_steps(
|
|
368
|
+
committed_step.get("steps", []), fresh_step.get("steps", [])
|
|
369
|
+
)
|
|
370
|
+
out["attrs"] = _merged_attrs(
|
|
371
|
+
committed_step.get("attrs", {}), fresh_step.get("attrs", {})
|
|
372
|
+
)
|
|
373
|
+
out["locks"] = copy.deepcopy(committed_step.get("locks", []))
|
|
374
|
+
for lock in out["locks"]:
|
|
375
|
+
_apply_lock(out, committed_step, lock["path"])
|
|
376
|
+
merged.append(out)
|
|
377
|
+
|
|
378
|
+
# Committed-only steps survive when they carry protection (locks anywhere
|
|
379
|
+
# in their subtree, or a user origin); analysis leftovers are superseded.
|
|
380
|
+
for position, committed_step in enumerate(committed):
|
|
381
|
+
if committed_step["id"] in fresh_ids:
|
|
382
|
+
continue
|
|
383
|
+
subtree_protected = _is_protected(committed_step) or any(
|
|
384
|
+
_is_protected(child) for child in _walk_dumps(committed_step)
|
|
385
|
+
)
|
|
386
|
+
if not subtree_protected:
|
|
387
|
+
continue
|
|
388
|
+
insert_at = len(merged)
|
|
389
|
+
for earlier in reversed(committed[:position]):
|
|
390
|
+
index = _find_index(merged, earlier["id"])
|
|
391
|
+
if index is not None:
|
|
392
|
+
insert_at = index + 1
|
|
393
|
+
break
|
|
394
|
+
else:
|
|
395
|
+
insert_at = 0 if position == 0 else len(merged)
|
|
396
|
+
merged.insert(insert_at, copy.deepcopy(committed_step))
|
|
397
|
+
return merged
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _walk_dumps(step_dump: Mapping):
|
|
401
|
+
for child in step_dump.get("steps", []):
|
|
402
|
+
yield child
|
|
403
|
+
yield from _walk_dumps(child)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _find_index(steps: list[dict], step_id: str) -> int | None:
|
|
407
|
+
for i, step in enumerate(steps):
|
|
408
|
+
if step["id"] == step_id:
|
|
409
|
+
return i
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _union_by_id(committed: list[dict], fresh: list[dict]) -> list[dict]:
|
|
414
|
+
"""Fresh entries win for shared ids; committed-only entries are kept."""
|
|
415
|
+
fresh_ids = {item["id"] for item in fresh}
|
|
416
|
+
return copy.deepcopy(fresh) + [
|
|
417
|
+
copy.deepcopy(item) for item in committed if item["id"] not in fresh_ids
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def merge_regenerated(committed: StepDocument, fresh: StepDocument) -> StepDocument:
|
|
422
|
+
"""Merge a fresh analysis projection against the committed document.
|
|
423
|
+
|
|
424
|
+
The rules, in order of authority:
|
|
425
|
+
|
|
426
|
+
1. **Locked paths keep the committed value** — on the document and on
|
|
427
|
+
every step matched by id, recursively — re-applied by structural
|
|
428
|
+
match, never by bare position (a reorder must not land a locked value
|
|
429
|
+
on the wrong item).
|
|
430
|
+
2. Everything else takes the fresh value (regeneration is allowed to
|
|
431
|
+
improve what nobody protected) — except ``attrs``, where committed
|
|
432
|
+
wins per key.
|
|
433
|
+
3. Committed-only steps survive when protected (locks in their subtree or
|
|
434
|
+
a ``user:`` origin); unprotected ones are superseded analysis output.
|
|
435
|
+
4. Cues, questions and sources are unioned by id (fresh wins on shared
|
|
436
|
+
ids) — analysis rarely regenerates them, and dropping committed
|
|
437
|
+
content silently is the failure this module exists to prevent.
|
|
438
|
+
|
|
439
|
+
Edits made through :func:`apply_edits` are always locked, so "hand edit"
|
|
440
|
+
and "protected" coincide by construction; edits made by hand-editing the
|
|
441
|
+
JSON without locks are, deliberately, not protected.
|
|
442
|
+
"""
|
|
443
|
+
committed_dump = committed.model_dump(mode="python", by_alias=False)
|
|
444
|
+
out = fresh.model_dump(mode="python", by_alias=False)
|
|
445
|
+
out["steps"] = _merge_steps(committed_dump["steps"], out["steps"])
|
|
446
|
+
for key in ("cues", "questions", "sources"):
|
|
447
|
+
out[key] = _union_by_id(committed_dump[key], out[key])
|
|
448
|
+
out["attrs"] = _merged_attrs(committed_dump.get("attrs", {}), out.get("attrs", {}))
|
|
449
|
+
out["locks"] = copy.deepcopy(committed_dump.get("locks", []))
|
|
450
|
+
for lock in out["locks"]:
|
|
451
|
+
_apply_lock(out, committed_dump, lock["path"])
|
|
452
|
+
return StepDocument.model_validate(out)
|
|
@@ -231,6 +231,7 @@ class StepDocument(_Base):
|
|
|
231
231
|
cues: list[Cue] = Field(default_factory=list)
|
|
232
232
|
questions: list[OpenQuestion] = Field(default_factory=list)
|
|
233
233
|
credits: str | None = None
|
|
234
|
+
locks: list[Lock] = Field(default_factory=list) # document-level fields
|
|
234
235
|
attrs: dict[str, Any] = Field(default_factory=dict)
|
|
235
236
|
|
|
236
237
|
|
|
@@ -322,13 +323,17 @@ def validate_document(doc: StepDocument) -> list[str]:
|
|
|
322
323
|
(empty list = clean); never raises.
|
|
323
324
|
|
|
324
325
|
Checks: children durations account for the parent's (``repeat`` included),
|
|
325
|
-
span sources exist, cue anchors point at real steps
|
|
326
|
+
span sources exist, cue anchors point at real steps, step ids are unique
|
|
327
|
+
(id-addressed edits and the regeneration merge both key on them — a
|
|
328
|
+
duplicate makes those silently ambiguous).
|
|
326
329
|
"""
|
|
327
330
|
issues: list[str] = []
|
|
328
331
|
source_ids = {s.id for s in doc.sources}
|
|
329
332
|
step_ids: set[str] = set()
|
|
330
333
|
|
|
331
334
|
def _walk(step: Step, path: str) -> None:
|
|
335
|
+
if step.id in step_ids:
|
|
336
|
+
issues.append(f"{path}: duplicate step id {step.id!r}")
|
|
332
337
|
step_ids.add(step.id)
|
|
333
338
|
for i, span in enumerate(step.spans):
|
|
334
339
|
if span.source not in source_ids:
|