cjm-transcript-correction-core 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cjm_transcript_correction_core/__init__.py +1 -0
- cjm_transcript_correction_core/cli.py +193 -0
- cjm_transcript_correction_core/graph.py +783 -0
- cjm_transcript_correction_core/models.py +171 -0
- cjm_transcript_correction_core/pipeline.py +489 -0
- cjm_transcript_correction_core/signals.py +208 -0
- cjm_transcript_correction_core-0.0.1.dist-info/METADATA +135 -0
- cjm_transcript_correction_core-0.0.1.dist-info/RECORD +12 -0
- cjm_transcript_correction_core-0.0.1.dist-info/WHEEL +5 -0
- cjm_transcript_correction_core-0.0.1.dist-info/entry_points.txt +2 -0
- cjm_transcript_correction_core-0.0.1.dist-info/licenses/LICENSE +201 -0
- cjm_transcript_correction_core-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
"""The correction overlay's graph I/O: targeted (scale-shaped) reads of a committed spine via the graph-storage query action, construction of Correction / CorrectionSession nodes + CORRECTS / SUPERSEDES / DERIVED_FROM / REVIEWED edges, the in-core effective-spine projection (layer-0 + applied corrections), and commit through the job queue. Hand-rolled (revolution-1) = direct CR-18 spec material; append-only on layer-0 (never update/delete a Segment)."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from cjm_context_graph_layer.edits import (project_effective_spine as layer_project_effective_spine,
|
|
8
|
+
resolve_active, SpineEdit, SpineUnit)
|
|
9
|
+
from cjm_context_graph_layer.grammar import make_edge, OverlayRelations, SpineRelations
|
|
10
|
+
from cjm_context_graph_layer.ops import extend_graph, graph_task
|
|
11
|
+
from cjm_context_graph_primitives.graph import GraphNode
|
|
12
|
+
from cjm_context_graph_primitives.locators import locator_from_dict
|
|
13
|
+
from cjm_context_graph_primitives.query import (EdgeQuery, EdgeQueryResult, NodeQuery,
|
|
14
|
+
NodeQueryResult, OrderBy, PropertyPredicate,
|
|
15
|
+
RelationPredicate, SourcePredicate)
|
|
16
|
+
from cjm_substrate.core.queue import JobQueue, JobStatus
|
|
17
|
+
from cjm_transcript_correction_core.models import (Correction, CorrectionRelations,
|
|
18
|
+
CorrectionSession, SpineSegment)
|
|
19
|
+
from cjm_transcript_graph_schema.schema import TranscriptGraphLabels
|
|
20
|
+
|
|
21
|
+
# Stage 4: the typed query surface — importing the result classes IS the
|
|
22
|
+
# host-side wire registration (F8); the tuple keeps these SIDE-EFFECT imports
|
|
23
|
+
# referenced so the canonical emit cannot prune them. Stage 5 (CR-18): the
|
|
24
|
+
# layer owns the shared plumbing + the effective-view machinery this core
|
|
25
|
+
# hand-rolled at revolution 1 (C5/C11/C16 migrated).
|
|
26
|
+
_REGISTERED_WIRE_KINDS = (NodeQueryResult, EdgeQueryResult)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def submit_and_wait(
|
|
30
|
+
queue: JobQueue, # Started job queue
|
|
31
|
+
instance_id: str, # Capability instance to invoke
|
|
32
|
+
*,
|
|
33
|
+
timeout: Optional[float] = None, # Seconds to wait; None = no limit
|
|
34
|
+
**kwargs, # Forwarded to the capability action
|
|
35
|
+
) -> Any: # Completed job result payload
|
|
36
|
+
"""Submit one capability job, wait for it, return its result (raise on failure).
|
|
37
|
+
|
|
38
|
+
(Restored as its own cell after the stage-2 field_of retirement removed
|
|
39
|
+
the shared cell that bundled both functions — the loop-back harness
|
|
40
|
+
caught the casualty; one-fn-per-cell prevents the recurrence.)
|
|
41
|
+
"""
|
|
42
|
+
job_id = await queue.submit(instance_id, **kwargs)
|
|
43
|
+
job = await queue.wait_for_job(job_id, timeout=timeout)
|
|
44
|
+
if job.status != JobStatus.completed:
|
|
45
|
+
raise RuntimeError(f"{instance_id} job {job_id} {job.status}: {job.error}")
|
|
46
|
+
return job.result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Targeted, scale-shaped reads of one SOURCE's fine spine. The fine spine hangs
|
|
50
|
+
# under an AudioRendition (raw | vocals | ...) under an AudioSegment under the
|
|
51
|
+
# Source, so scoping is: coarse read (AudioSegments) -> rendition resolve (which
|
|
52
|
+
# rendition's spine) -> batched far-end constraint over the rendition ids.
|
|
53
|
+
|
|
54
|
+
_SPINE_PROJECTION = ["index", "text", "start_time", "end_time", "text_from", "sources"] # + structural "id"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _row_to_spine_segment(row: Dict[str, Any]) -> SpineSegment: # One projected row -> SpineSegment
|
|
58
|
+
"""Build a SpineSegment from a projected row (audio anchor + per-transcriber slices)."""
|
|
59
|
+
sources = row.get("sources") or []
|
|
60
|
+
text_from = row.get("text_from")
|
|
61
|
+
audio = next((s for s in sources if (s.get("slice") or {}).get("kind") == "time"), None)
|
|
62
|
+
slices: List[Dict[str, Any]] = []
|
|
63
|
+
auth_hash = None
|
|
64
|
+
for s in sources:
|
|
65
|
+
sl = s.get("slice") or {}
|
|
66
|
+
if sl.get("kind") != "char":
|
|
67
|
+
continue
|
|
68
|
+
tid = (s.get("locator") or {}).get("node_id")
|
|
69
|
+
slices.append({"transcript": tid, "start": sl.get("start"), "end": sl.get("end"),
|
|
70
|
+
"content_hash": s.get("content_hash")})
|
|
71
|
+
if tid and tid == text_from:
|
|
72
|
+
auth_hash = s.get("content_hash")
|
|
73
|
+
idx = row.get("index")
|
|
74
|
+
return SpineSegment(
|
|
75
|
+
id=row["id"], index=int(idx) if idx is not None else -1,
|
|
76
|
+
text=row.get("text") or "", start_time=row.get("start_time"),
|
|
77
|
+
end_time=row.get("end_time"),
|
|
78
|
+
source_locator=(str(locator_from_dict(audio["locator"])) if audio and audio.get("locator") else None),
|
|
79
|
+
content_hash=auth_hash, text_from=text_from, text_slices=slices,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def source_audio_segment_ids(
|
|
84
|
+
queue: JobQueue, # Started job queue
|
|
85
|
+
graph_id: str, # Graph-storage capability id
|
|
86
|
+
source_id: str, # Source node id
|
|
87
|
+
) -> List[str]: # Ordered AudioSegment node ids under the Source
|
|
88
|
+
"""The Source's coarse spine (one small typed read; ordered by index)."""
|
|
89
|
+
q = NodeQuery(label=TranscriptGraphLabels.AUDIO_SEGMENT,
|
|
90
|
+
related=RelationPredicate(SpineRelations.PART_OF, node_id=source_id),
|
|
91
|
+
order_by=OrderBy(prop="index"), project=["index"])
|
|
92
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
|
|
93
|
+
return [r["id"] for r in (res.rows or [])]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _rendition_label(r: Dict[str, Any]) -> str: # Human-readable rendition label
|
|
97
|
+
"""A rendition's selector/display label ("raw" or its preprocessing chain)."""
|
|
98
|
+
if r.get("is_raw"):
|
|
99
|
+
return "raw"
|
|
100
|
+
return str(r.get("preprocessing") or "|".join(r.get("chain") or []) or "raw")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def resolve_source_renditions(
|
|
104
|
+
queue: JobQueue, # Started job queue
|
|
105
|
+
graph_id: str, # Graph-storage capability id
|
|
106
|
+
source_id: str, # Source node id
|
|
107
|
+
selector: Optional[str] = None, # Which rendition: "raw" | a preprocessing-descriptor substring | None = auto
|
|
108
|
+
) -> List[str]: # The AudioRendition ids whose fine spine to operate on (one chain group)
|
|
109
|
+
"""Pick the AudioRendition set whose fine Segment spine correction operates on.
|
|
110
|
+
|
|
111
|
+
The fine spine hangs under renditions (raw | vocals | ...) that COEXIST under
|
|
112
|
+
one AudioSegment. With ONE decomposed rendition (the common case) it is
|
|
113
|
+
selected automatically; multiple decomposed renditions REQUIRE an explicit
|
|
114
|
+
`selector` ("raw", or a substring of the preprocessing descriptor) — the
|
|
115
|
+
spines are never silently mixed. Returns the rendition ids of the chosen
|
|
116
|
+
chain group (one per AudioSegment), or [] when nothing is decomposed yet.
|
|
117
|
+
"""
|
|
118
|
+
aseg_ids = await source_audio_segment_ids(queue, graph_id, source_id)
|
|
119
|
+
if not aseg_ids:
|
|
120
|
+
return []
|
|
121
|
+
rq = NodeQuery(label=TranscriptGraphLabels.AUDIO_RENDITION,
|
|
122
|
+
related=RelationPredicate(OverlayRelations.DERIVED_FROM, node_ids=aseg_ids),
|
|
123
|
+
project=["chain", "is_raw", "preprocessing"])
|
|
124
|
+
rres = await graph_task(queue, graph_id, "query_nodes", query=rq.to_dict())
|
|
125
|
+
rends = [{"id": r["id"], "chain": tuple(r.get("chain") or []),
|
|
126
|
+
"is_raw": bool(r.get("is_raw")), "preprocessing": r.get("preprocessing")}
|
|
127
|
+
for r in (rres.rows or [])]
|
|
128
|
+
if not rends:
|
|
129
|
+
return []
|
|
130
|
+
by_chain: Dict[tuple, List[Dict[str, Any]]] = {}
|
|
131
|
+
for r in rends:
|
|
132
|
+
by_chain.setdefault(r["chain"], []).append(r)
|
|
133
|
+
labels = sorted({_rendition_label(rs[0]) for rs in by_chain.values()})
|
|
134
|
+
|
|
135
|
+
if selector is not None:
|
|
136
|
+
sel = selector.strip().lower()
|
|
137
|
+
for rs in by_chain.values():
|
|
138
|
+
if (sel == "raw" and rs[0]["is_raw"]) or \
|
|
139
|
+
(rs[0].get("preprocessing") and sel in str(rs[0]["preprocessing"]).lower()):
|
|
140
|
+
return [r["id"] for r in rs]
|
|
141
|
+
raise ValueError(f"--rendition {selector!r} matches no rendition for source "
|
|
142
|
+
f"{source_id} (available: {labels})")
|
|
143
|
+
|
|
144
|
+
# Auto: the chain group that actually carries Segments.
|
|
145
|
+
populated = await _populated_rendition_ids(queue, graph_id, [r["id"] for r in rends])
|
|
146
|
+
populated_chains = {r["chain"] for r in rends if r["id"] in populated}
|
|
147
|
+
if not populated_chains:
|
|
148
|
+
return []
|
|
149
|
+
if len(populated_chains) == 1:
|
|
150
|
+
ck = next(iter(populated_chains))
|
|
151
|
+
return [r["id"] for r in by_chain[ck]]
|
|
152
|
+
pop_labels = sorted({_rendition_label(by_chain[c][0]) for c in populated_chains})
|
|
153
|
+
raise ValueError(f"source {source_id} has multiple decomposed renditions ({pop_labels}); "
|
|
154
|
+
"pass --rendition to choose which spine to correct")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _populated_rendition_ids(
|
|
158
|
+
queue: JobQueue, # Started job queue
|
|
159
|
+
graph_id: str, # Graph-storage capability id
|
|
160
|
+
rendition_ids: List[str], # Candidate rendition ids
|
|
161
|
+
) -> set: # Subset that own >=1 fine Segment
|
|
162
|
+
"""Which renditions actually carry a fine Segment spine (one batched read)."""
|
|
163
|
+
if not rendition_ids:
|
|
164
|
+
return set()
|
|
165
|
+
q = NodeQuery(label=TranscriptGraphLabels.SEGMENT,
|
|
166
|
+
related=RelationPredicate(SpineRelations.PART_OF, node_ids=list(rendition_ids)),
|
|
167
|
+
project=["rendition_id"])
|
|
168
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
|
|
169
|
+
return {r.get("rendition_id") for r in (res.rows or []) if r.get("rendition_id")}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _spine_query(
|
|
173
|
+
rendition_ids: List[str], # The AudioRendition ids the spine hangs under
|
|
174
|
+
**overrides, # NodeQuery field overrides (where / count / limit / ...)
|
|
175
|
+
) -> NodeQuery: # The source-spine read
|
|
176
|
+
"""Segments PART_OF the chosen AudioRenditions (batched far-end), ordered by index."""
|
|
177
|
+
base: Dict[str, Any] = dict(
|
|
178
|
+
label=TranscriptGraphLabels.SEGMENT,
|
|
179
|
+
related=RelationPredicate(SpineRelations.PART_OF, node_ids=list(rendition_ids)),
|
|
180
|
+
order_by=OrderBy(prop="index"),
|
|
181
|
+
project=list(_SPINE_PROJECTION),
|
|
182
|
+
)
|
|
183
|
+
base.update(overrides)
|
|
184
|
+
return NodeQuery(**base)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def load_source_segments(
|
|
188
|
+
queue: JobQueue, # Started job queue
|
|
189
|
+
graph_id: str, # Graph-storage capability id
|
|
190
|
+
source_id: str, # Source node id
|
|
191
|
+
limit: Optional[int] = None, # Optional page size
|
|
192
|
+
offset: Optional[int] = None, # Optional page offset
|
|
193
|
+
rendition_selector: Optional[str] = None, # Which rendition spine (None = auto-select)
|
|
194
|
+
) -> List[SpineSegment]: # Ordered spine segments (by index)
|
|
195
|
+
"""Load a Source's fine Segment spine under its chosen rendition (typed query surface)."""
|
|
196
|
+
rendition_ids = await resolve_source_renditions(queue, graph_id, source_id, rendition_selector)
|
|
197
|
+
if not rendition_ids:
|
|
198
|
+
return []
|
|
199
|
+
q = _spine_query(rendition_ids, limit=limit, offset=int(offset or 0))
|
|
200
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
|
|
201
|
+
return [_row_to_spine_segment(r) for r in (res.rows or [])]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def load_empty_segments(
|
|
205
|
+
queue: JobQueue, # Started job queue
|
|
206
|
+
graph_id: str, # Graph-storage capability id
|
|
207
|
+
source_id: str, # Source node id
|
|
208
|
+
rendition_selector: Optional[str] = None, # Which rendition spine (None = auto-select)
|
|
209
|
+
) -> List[SpineSegment]: # Only the empty-text segments (server-side filtered)
|
|
210
|
+
"""Load ONLY a Source's empty-text segments under its chosen rendition (D14 prune).
|
|
211
|
+
|
|
212
|
+
The evidenced OR case (text = '' OR text IS NULL) = TWO server-side-filtered
|
|
213
|
+
queries unioned client-side — compound boolean predicates stay deferred (P8);
|
|
214
|
+
both halves materialize ~10% of the spine, never the whole source.
|
|
215
|
+
"""
|
|
216
|
+
rendition_ids = await resolve_source_renditions(queue, graph_id, source_id, rendition_selector)
|
|
217
|
+
if not rendition_ids:
|
|
218
|
+
return []
|
|
219
|
+
r1 = await graph_task(queue, graph_id, "query_nodes", query=_spine_query(
|
|
220
|
+
rendition_ids, where=[PropertyPredicate("text", "eq", "")]).to_dict())
|
|
221
|
+
r2 = await graph_task(queue, graph_id, "query_nodes", query=_spine_query(
|
|
222
|
+
rendition_ids, where=[PropertyPredicate("text", "is_null")]).to_dict())
|
|
223
|
+
segs = [_row_to_spine_segment(r) for r in (r1.rows or []) + (r2.rows or [])]
|
|
224
|
+
segs.sort(key=lambda s: s.index)
|
|
225
|
+
return segs
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def count_source_segments(
|
|
229
|
+
queue: JobQueue, # Started job queue
|
|
230
|
+
graph_id: str, # Graph-storage capability id
|
|
231
|
+
source_id: str, # Source node id
|
|
232
|
+
rendition_selector: Optional[str] = None, # Which rendition spine (None = auto-select)
|
|
233
|
+
) -> int: # Number of fine Segment nodes under the Source's chosen rendition
|
|
234
|
+
"""Count a Source's segments server-side under its chosen rendition (typed count mode)."""
|
|
235
|
+
rendition_ids = await resolve_source_renditions(queue, graph_id, source_id, rendition_selector)
|
|
236
|
+
if not rendition_ids:
|
|
237
|
+
return 0
|
|
238
|
+
q = _spine_query(rendition_ids, order_by=None, project=None, count=True)
|
|
239
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
|
|
240
|
+
return int(res.count or 0)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _edge(
|
|
244
|
+
source_id: str, # Origin node id
|
|
245
|
+
target_id: str, # Destination node id
|
|
246
|
+
relation_type: str, # Edge relation type
|
|
247
|
+
properties: Optional[Dict[str, Any]] = None, # Edge properties
|
|
248
|
+
) -> Dict[str, Any]: # Edge wire dict
|
|
249
|
+
"""Build an edge wire dict with a GENERATED id.
|
|
250
|
+
|
|
251
|
+
Used for REVIEWED markers only: review decisions are EVENTS (a re-decision
|
|
252
|
+
appends), so their edges keep generated ids. Structural overlay edges
|
|
253
|
+
(CORRECTS / SUPERSEDES / DERIVED_FROM) use the layer's deterministic
|
|
254
|
+
`make_edge` — unique per (source, target, relation) by construction.
|
|
255
|
+
"""
|
|
256
|
+
return {"id": str(uuid4()), "source_id": source_id, "target_id": target_id,
|
|
257
|
+
"relation_type": relation_type, "properties": properties or {}}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def build_correction_node(
|
|
261
|
+
correction_type: str, # "text_content" | "punctuation" | "grouping" | "review"
|
|
262
|
+
session_id: str, # Owning session id
|
|
263
|
+
payload: Dict[str, Any], # Type-specific payload
|
|
264
|
+
actor: str = "human", # Actor
|
|
265
|
+
status: str = "applied", # Lifecycle status
|
|
266
|
+
canonical_form: Optional[str] = None, # Optional entity key
|
|
267
|
+
rationale: Optional[str] = None, # Optional note
|
|
268
|
+
) -> Correction: # The Correction overlay node (not yet committed)
|
|
269
|
+
"""Construct a Correction overlay node (pure; commit happens separately)."""
|
|
270
|
+
return Correction(
|
|
271
|
+
correction_type=correction_type, session_id=session_id, payload=payload,
|
|
272
|
+
actor=actor, status=status, canonical_form=canonical_form, rationale=rationale,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def build_prune_correction(
|
|
277
|
+
source_id: str, # Source being corrected
|
|
278
|
+
pruned: List[SpineSegment], # Empty layer-0 segments to prune
|
|
279
|
+
session_id: str, # Owning session id
|
|
280
|
+
actor: str = "human", # Actor
|
|
281
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: # (correction node dict, edge dicts)
|
|
282
|
+
"""Build one batch grouping Correction that prunes empty segments (D14).
|
|
283
|
+
|
|
284
|
+
Non-destructive: layer-0 nodes are NOT deleted; the Correction records the
|
|
285
|
+
pruned ids and DERIVED_FROM edges point at each pruned Segment. The effective
|
|
286
|
+
spine drops them at projection time (reversible by superseding this node).
|
|
287
|
+
The payload carries the layer's `prune` spine-edit op vocabulary.
|
|
288
|
+
"""
|
|
289
|
+
payload = {
|
|
290
|
+
"operation": "prune_empty",
|
|
291
|
+
"source_id": source_id,
|
|
292
|
+
"pruned_segment_ids": [s.id for s in pruned],
|
|
293
|
+
"pruned_count": len(pruned),
|
|
294
|
+
}
|
|
295
|
+
node = build_correction_node("grouping", session_id, payload, actor=actor).to_graph_node()
|
|
296
|
+
edges = [make_edge(node.id, s.id, CorrectionRelations.DERIVED_FROM) for s in pruned]
|
|
297
|
+
return node.to_dict(), edges
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def commit_nodes_edges(
|
|
301
|
+
queue: JobQueue, # Started job queue
|
|
302
|
+
graph_id: str, # Graph-storage capability id
|
|
303
|
+
nodes: List[Dict[str, Any]], # Node wire dicts
|
|
304
|
+
edges: List[Dict[str, Any]], # Edge wire dicts
|
|
305
|
+
) -> Dict[str, int]: # {"nodes": n, "edges": m} created counts
|
|
306
|
+
"""Commit overlay nodes/edges through the layer's idempotent extend_graph.
|
|
307
|
+
|
|
308
|
+
Stage 5 (C5 plumbing migrated): the layer owns emit-if-absent +
|
|
309
|
+
verify-if-present; overlay nodes have generated ids so they always add,
|
|
310
|
+
but a replayed commit collides into a verified no-op instead of
|
|
311
|
+
duplicating structural edges.
|
|
312
|
+
"""
|
|
313
|
+
res = await extend_graph(queue, graph_id, nodes, edges)
|
|
314
|
+
return {"nodes": res.nodes_added, "edges": res.edges_added}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def start_session(
|
|
318
|
+
queue: JobQueue, # Started job queue
|
|
319
|
+
graph_id: str, # Graph-storage capability id
|
|
320
|
+
scope: List[str], # Source node ids in scope
|
|
321
|
+
) -> CorrectionSession: # The committed CorrectionSession
|
|
322
|
+
"""Create + commit a new CorrectionSession node."""
|
|
323
|
+
sess = CorrectionSession(scope=scope)
|
|
324
|
+
await commit_nodes_edges(queue, graph_id, [sess.to_graph_node().to_dict()], [])
|
|
325
|
+
return sess
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
async def get_session(
|
|
329
|
+
queue: JobQueue, # Started job queue
|
|
330
|
+
graph_id: str, # Graph-storage capability id
|
|
331
|
+
session_id: str, # CorrectionSession node id
|
|
332
|
+
) -> Optional[Dict[str, Any]]: # The session node dict, or None
|
|
333
|
+
"""Fetch a CorrectionSession node by id (resume/reopen) — typed get, dict shape preserved."""
|
|
334
|
+
node = await graph_task(queue, graph_id, "get_node", node_id=session_id)
|
|
335
|
+
if node is None:
|
|
336
|
+
return None
|
|
337
|
+
return node.to_dict() if isinstance(node, GraphNode) else node
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
async def set_session_status(
|
|
341
|
+
queue: JobQueue, # Started job queue
|
|
342
|
+
graph_id: str, # Graph-storage capability id
|
|
343
|
+
session_id: str, # CorrectionSession node id
|
|
344
|
+
status: str, # New status ("in_progress" | "completed" | "reopened")
|
|
345
|
+
) -> None:
|
|
346
|
+
"""Update a session's status + updated_at.
|
|
347
|
+
|
|
348
|
+
The ONLY update_node use in this core: a CorrectionSession is OVERLAY metadata
|
|
349
|
+
whose lifecycle is mutable. Layer-0 Segments + Corrections stay append-only.
|
|
350
|
+
"""
|
|
351
|
+
await graph_task(queue, graph_id, "update_node", node_id=session_id,
|
|
352
|
+
properties={"status": status, "updated_at": time.time()})
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
async def record_review_markers(
|
|
356
|
+
queue: JobQueue, # Started job queue
|
|
357
|
+
graph_id: str, # Graph-storage capability id
|
|
358
|
+
session_id: str, # Owning session id
|
|
359
|
+
decisions: List[Tuple[str, str]], # (segment_id, decision) pairs
|
|
360
|
+
) -> int: # Number of REVIEWED edges committed
|
|
361
|
+
"""Persist per-(session, segment) review markers as REVIEWED edges."""
|
|
362
|
+
edges = [_edge(session_id, seg_id, CorrectionRelations.REVIEWED, {"decision": dec})
|
|
363
|
+
for seg_id, dec in decisions]
|
|
364
|
+
return (await commit_nodes_edges(queue, graph_id, [], edges))["edges"]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
async def load_review_markers(
|
|
368
|
+
queue: JobQueue, # Started job queue
|
|
369
|
+
graph_id: str, # Graph-storage capability id
|
|
370
|
+
session_id: str, # Owning session id
|
|
371
|
+
) -> Dict[str, str]: # segment_id -> decision for the session
|
|
372
|
+
"""Load a session's review markers (typed edge projection over REVIEWED edges)."""
|
|
373
|
+
q = EdgeQuery(source_id=session_id, relation_type=CorrectionRelations.REVIEWED,
|
|
374
|
+
project=["decision"])
|
|
375
|
+
res = await graph_task(queue, graph_id, "query_edges", query=q.to_dict())
|
|
376
|
+
return {r["target_id"]: r["decision"] for r in (res.rows or [])}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _node_to_correction_dict(
|
|
380
|
+
node: Any, # GraphNode (typed task result) or its wire dict
|
|
381
|
+
) -> Dict[str, Any]: # Correction properties + "id" (the pre-stage-4 row shape)
|
|
382
|
+
"""Flatten a Correction node to its properties dict + id."""
|
|
383
|
+
d = node.to_dict() if isinstance(node, GraphNode) else dict(node)
|
|
384
|
+
props = dict(d.get("properties", {}) or {})
|
|
385
|
+
props["id"] = d["id"]
|
|
386
|
+
return props
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def find_corrections_for_session(
|
|
390
|
+
queue: JobQueue, # Started job queue
|
|
391
|
+
graph_id: str, # Graph-storage capability id
|
|
392
|
+
session_id: str, # Owning session id
|
|
393
|
+
) -> List[Dict[str, Any]]: # Correction property dicts for the session
|
|
394
|
+
"""List corrections recorded in a session (typed property filter)."""
|
|
395
|
+
q = NodeQuery(label="Correction",
|
|
396
|
+
where=[PropertyPredicate("session_id", "eq", session_id)])
|
|
397
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
|
|
398
|
+
return [_node_to_correction_dict(n) for n in (res.nodes or [])]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
async def find_prior_corrections_by_hash(
|
|
402
|
+
queue: JobQueue, # Started job queue
|
|
403
|
+
graph_id: str, # Graph-storage capability id
|
|
404
|
+
content_hash: str, # SourceRef content hash to look up
|
|
405
|
+
) -> List[Dict[str, Any]]: # Corrections whose CORRECTS target carried this hash
|
|
406
|
+
"""Cross-transcript correction-cache lookup (targeted; the graph IS the lexicon).
|
|
407
|
+
|
|
408
|
+
THE stage-4 promotion landing: the raw two-table JOIN this function carried
|
|
409
|
+
(C-ledger site 6) became ONE typed far-end provenance constraint
|
|
410
|
+
(`RelationPredicate.node_source`, content-hash-primary per CR-19) — the
|
|
411
|
+
hottest review-path read is portable now.
|
|
412
|
+
"""
|
|
413
|
+
q = NodeQuery(label="Correction",
|
|
414
|
+
related=RelationPredicate(
|
|
415
|
+
CorrectionRelations.CORRECTS,
|
|
416
|
+
node_source=SourcePredicate(content_hash=content_hash)))
|
|
417
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
|
|
418
|
+
return [_node_to_correction_dict(n) for n in (res.nodes or [])]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def corrections_to_edits(
|
|
422
|
+
corrections: List[Dict[str, Any]], # ACTIVE correction property dicts
|
|
423
|
+
) -> List[SpineEdit]: # The layer's neutral spine-edit operations
|
|
424
|
+
"""Map this core's Correction payloads onto the layer's spine-edit vocabulary.
|
|
425
|
+
|
|
426
|
+
The DOMAIN interpretation (correction_type + payload shapes) stays here;
|
|
427
|
+
the projection MECHANICS (ordering, latest-wins, prune/replace/boundary
|
|
428
|
+
semantics) are the layer's (CR-18: C11 migrated).
|
|
429
|
+
"""
|
|
430
|
+
edits: List[SpineEdit] = []
|
|
431
|
+
for c in corrections:
|
|
432
|
+
if c.get("status") in ("superseded", "proposed"):
|
|
433
|
+
# superseded: legacy skip retained; proposed: awaiting a review
|
|
434
|
+
# verdict — a proposal never enters the effective view (DEC 7861d27e).
|
|
435
|
+
continue
|
|
436
|
+
ctype = c.get("correction_type")
|
|
437
|
+
payload = c.get("payload", {}) or {}
|
|
438
|
+
created = float(c.get("created_at") or 0.0)
|
|
439
|
+
if ctype == "grouping" and payload.get("operation") == "prune_empty":
|
|
440
|
+
edits.append(SpineEdit(edit_id=c["id"], op="prune",
|
|
441
|
+
targets=list(payload.get("pruned_segment_ids") or []),
|
|
442
|
+
created_at=created))
|
|
443
|
+
elif ctype == "grouping" and payload.get("operation") == "shift_boundary":
|
|
444
|
+
left = payload.get("boundary_after")
|
|
445
|
+
if left is not None:
|
|
446
|
+
edits.append(SpineEdit(edit_id=c["id"], op="boundary_shift",
|
|
447
|
+
targets=[t for t in (left, payload.get("right_segment_id")) if t],
|
|
448
|
+
payload={"boundary_after": left,
|
|
449
|
+
"text": payload.get("text", ""),
|
|
450
|
+
"direction": payload.get("direction", "push")},
|
|
451
|
+
created_at=created))
|
|
452
|
+
elif ctype == "text_content" and payload.get("operation") == "replace_text":
|
|
453
|
+
sid = payload.get("segment_id")
|
|
454
|
+
if sid is not None:
|
|
455
|
+
edits.append(SpineEdit(edit_id=c["id"], op="replace_text", targets=[sid],
|
|
456
|
+
payload={"text": payload.get("new_text", "")},
|
|
457
|
+
created_at=created))
|
|
458
|
+
return edits
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def project_effective_spine(
|
|
462
|
+
segments: List[SpineSegment], # Ordered layer-0 spine
|
|
463
|
+
corrections: List[Dict[str, Any]], # Applied correction property dicts
|
|
464
|
+
) -> List[SpineSegment]: # The effective spine after applying corrections
|
|
465
|
+
"""Project the effective spine = layer-0 + applied corrections.
|
|
466
|
+
|
|
467
|
+
Stage 5: the projection MECHANICS live in `cjm-context-graph-layer`
|
|
468
|
+
(`project_effective_spine` over SpineUnits — prune, replace_text, and the
|
|
469
|
+
reserved boundary_shift, with created_at ordering + latest-wins); this
|
|
470
|
+
wrapper converts SpineSegments <-> SpineUnits and re-attaches the
|
|
471
|
+
segment metadata by id.
|
|
472
|
+
"""
|
|
473
|
+
units = [SpineUnit(id=s.id, text=s.text) for s in segments]
|
|
474
|
+
out_units = layer_project_effective_spine(units, corrections_to_edits(corrections))
|
|
475
|
+
by_id = {u.id: u.text for u in out_units}
|
|
476
|
+
out: List[SpineSegment] = []
|
|
477
|
+
for s in segments:
|
|
478
|
+
if s.id not in by_id:
|
|
479
|
+
continue
|
|
480
|
+
if by_id[s.id] != s.text:
|
|
481
|
+
s = SpineSegment(id=s.id, index=s.index, text=by_id[s.id],
|
|
482
|
+
start_time=s.start_time, end_time=s.end_time,
|
|
483
|
+
source_locator=s.source_locator, content_hash=s.content_hash,
|
|
484
|
+
text_from=s.text_from, text_slices=s.text_slices)
|
|
485
|
+
out.append(s)
|
|
486
|
+
return out
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def build_text_correction(
|
|
490
|
+
source_id: str, # Source the segment belongs to
|
|
491
|
+
segment_id: str, # Layer-0 Segment being corrected
|
|
492
|
+
new_text: str, # Corrected text
|
|
493
|
+
session_id: str, # Owning session id
|
|
494
|
+
old_text: Optional[str] = None, # Prior effective text (for the record)
|
|
495
|
+
supersedes_id: Optional[str] = None, # Prior Correction this one replaces (re-edit)
|
|
496
|
+
actor: str = "human", # Actor
|
|
497
|
+
canonical_form: Optional[str] = None, # Optional entity key (cross-transcript matching)
|
|
498
|
+
rationale: Optional[str] = None, # Optional note
|
|
499
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: # (correction node dict, edge dicts)
|
|
500
|
+
"""Build a text_content Correction + its CORRECTS (+ optional SUPERSEDES) edges.
|
|
501
|
+
|
|
502
|
+
Non-destructive: the layer-0 Segment is unchanged; the correction carries the
|
|
503
|
+
new text in its payload + a CORRECTS edge to the segment. A re-edit adds a
|
|
504
|
+
SUPERSEDES edge (new -> prior) so supersession is graph-native + append-only
|
|
505
|
+
(the prior Correction is never mutated; it is excluded from the effective view
|
|
506
|
+
because it is a SUPERSEDES target — the C16 semantics, layer-resolved).
|
|
507
|
+
"""
|
|
508
|
+
payload = {"operation": "replace_text", "source_id": source_id,
|
|
509
|
+
"segment_id": segment_id, "new_text": new_text, "old_text": old_text}
|
|
510
|
+
node = build_correction_node("text_content", session_id, payload, actor=actor,
|
|
511
|
+
canonical_form=canonical_form, rationale=rationale).to_graph_node()
|
|
512
|
+
edges = [make_edge(node.id, segment_id, CorrectionRelations.CORRECTS)]
|
|
513
|
+
if supersedes_id:
|
|
514
|
+
edges.append(make_edge(node.id, supersedes_id, CorrectionRelations.SUPERSEDES))
|
|
515
|
+
return node.to_dict(), edges
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
async def commit_text_correction(
|
|
519
|
+
queue: JobQueue, # Started job queue
|
|
520
|
+
graph_id: str, # Graph-storage capability id
|
|
521
|
+
source_id: str, # Source the segment belongs to
|
|
522
|
+
segment_id: str, # Layer-0 Segment being corrected
|
|
523
|
+
new_text: str, # Corrected text
|
|
524
|
+
session_id: str, # Owning session id
|
|
525
|
+
old_text: Optional[str] = None, # Prior effective text
|
|
526
|
+
supersedes_id: Optional[str] = None, # Prior Correction to supersede (re-edit)
|
|
527
|
+
actor: str = "human", # Actor
|
|
528
|
+
canonical_form: Optional[str] = None, # Optional entity key
|
|
529
|
+
) -> str: # The new Correction node id
|
|
530
|
+
"""Commit a text_content correction (node + CORRECTS [+ SUPERSEDES]) + a REVIEWED marker."""
|
|
531
|
+
node, edges = build_text_correction(
|
|
532
|
+
source_id, segment_id, new_text, session_id, old_text=old_text,
|
|
533
|
+
supersedes_id=supersedes_id, actor=actor, canonical_form=canonical_form)
|
|
534
|
+
await commit_nodes_edges(queue, graph_id, [node], edges)
|
|
535
|
+
await record_review_markers(queue, graph_id, session_id, [(segment_id, "corrected")])
|
|
536
|
+
return node["id"]
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def active_corrections(
|
|
540
|
+
corrections: List[Dict[str, Any]], # Corrections (e.g. from load_source_corrections)
|
|
541
|
+
superseded_ids: set, # Ids that are SUPERSEDES targets
|
|
542
|
+
) -> List[Dict[str, Any]]: # Only the effective (non-superseded) corrections
|
|
543
|
+
"""Filter to the effective correction set (the layer's resolve_active over a read superseded set)."""
|
|
544
|
+
active_ids = resolve_active([c.get("id") for c in corrections],
|
|
545
|
+
[("", t) for t in superseded_ids])
|
|
546
|
+
return [c for c in corrections if c.get("id") in active_ids]
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
async def _superseded_ids(
|
|
550
|
+
queue: JobQueue, # Started job queue
|
|
551
|
+
graph_id: str, # Graph-storage capability id
|
|
552
|
+
correction_ids: List[str], # Candidate correction ids
|
|
553
|
+
) -> set: # Subset that are SUPERSEDES targets
|
|
554
|
+
"""Which of the given corrections are SUPERSEDES targets (typed id-list batch)."""
|
|
555
|
+
if not correction_ids:
|
|
556
|
+
return set()
|
|
557
|
+
q = EdgeQuery(relation_type=CorrectionRelations.SUPERSEDES,
|
|
558
|
+
target_ids=list(correction_ids), project=[])
|
|
559
|
+
res = await graph_task(queue, graph_id, "query_edges", query=q.to_dict())
|
|
560
|
+
return {r["target_id"] for r in (res.rows or [])}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
async def load_source_corrections(
|
|
564
|
+
queue: JobQueue, # Started job queue
|
|
565
|
+
graph_id: str, # Graph-storage capability id
|
|
566
|
+
source_id: str, # Source node id
|
|
567
|
+
) -> Tuple[List[Dict[str, Any]], set]: # (all corrections for the source, superseded id set)
|
|
568
|
+
"""Load every Correction targeting a Source (across sessions) + the superseded-id set.
|
|
569
|
+
|
|
570
|
+
Source-scoped (corrections carry payload.source_id — a dotted-path typed
|
|
571
|
+
predicate) so the effective view is a property of the SOURCE, not one
|
|
572
|
+
session — the persistence/resume/reopen requirement. Append-only:
|
|
573
|
+
supersession is read from SUPERSEDES edges, never a status mutation.
|
|
574
|
+
"""
|
|
575
|
+
q = NodeQuery(label="Correction",
|
|
576
|
+
where=[PropertyPredicate("payload.source_id", "eq", source_id)])
|
|
577
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
|
|
578
|
+
corrections = [_node_to_correction_dict(n) for n in (res.nodes or [])]
|
|
579
|
+
superseded = await _superseded_ids(queue, graph_id, [c["id"] for c in corrections])
|
|
580
|
+
return corrections, superseded
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
async def find_active_text_corrections_batch(
|
|
584
|
+
queue: JobQueue, # Started job queue
|
|
585
|
+
graph_id: str, # Graph-storage capability id
|
|
586
|
+
segment_ids: List[str], # Segments to look up
|
|
587
|
+
) -> Dict[str, Dict[str, Any]]: # segment_id -> active text correction
|
|
588
|
+
"""Active text corrections for MANY segments in TWO round-trips (C17).
|
|
589
|
+
|
|
590
|
+
One far-end batch read (Corrections with CORRECTS edges into the id set;
|
|
591
|
+
`RelationPredicate.node_ids`) + one superseded-set read — replacing the
|
|
592
|
+
per-item lookup the review loop paid (a 1,275-item review would have been
|
|
593
|
+
1,275 queries; now 2). Latest non-superseded correction per segment wins.
|
|
594
|
+
"""
|
|
595
|
+
if not segment_ids:
|
|
596
|
+
return {}
|
|
597
|
+
q = NodeQuery(label="Correction",
|
|
598
|
+
where=[PropertyPredicate("correction_type", "eq", "text_content")],
|
|
599
|
+
related=RelationPredicate(CorrectionRelations.CORRECTS,
|
|
600
|
+
node_ids=list(segment_ids)))
|
|
601
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
|
|
602
|
+
cands = [_node_to_correction_dict(n) for n in (res.nodes or [])]
|
|
603
|
+
superseded = await _superseded_ids(queue, graph_id, [c["id"] for c in cands])
|
|
604
|
+
out: Dict[str, Dict[str, Any]] = {}
|
|
605
|
+
for c in cands:
|
|
606
|
+
if c["id"] in superseded:
|
|
607
|
+
continue
|
|
608
|
+
sid = (c.get("payload") or {}).get("segment_id")
|
|
609
|
+
if sid is None:
|
|
610
|
+
continue
|
|
611
|
+
prev = out.get(sid)
|
|
612
|
+
if prev is None or c.get("created_at", 0.0) > prev.get("created_at", 0.0):
|
|
613
|
+
out[sid] = c
|
|
614
|
+
return out
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
async def find_active_text_correction(
|
|
618
|
+
queue: JobQueue, # Started job queue
|
|
619
|
+
graph_id: str, # Graph-storage capability id
|
|
620
|
+
segment_id: str, # Segment to look up
|
|
621
|
+
) -> Optional[Dict[str, Any]]: # The current non-superseded text correction, or None
|
|
622
|
+
"""Single-segment convenience over the batch read (cross-session; latest wins)."""
|
|
623
|
+
found = await find_active_text_corrections_batch(queue, graph_id, [segment_id])
|
|
624
|
+
return found.get(segment_id)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
async def load_variant_texts(
|
|
628
|
+
queue: JobQueue, # Started job queue
|
|
629
|
+
graph_id: str, # Graph-storage capability id
|
|
630
|
+
segments: List[SpineSegment], # Spine segments (with text_slices)
|
|
631
|
+
) -> Dict[str, Dict[str, str]]: # segment_id -> {transcriber: chunk text}
|
|
632
|
+
"""Resolve per-transcriber chunk texts from the segments' CharSlice refs.
|
|
633
|
+
|
|
634
|
+
Stage 5: the cross-transcriber diff is INTRA-GRAPH — text is stored once
|
|
635
|
+
per transcriber at the coarse Transcript nodes; this fetches ALL referenced
|
|
636
|
+
Transcript nodes in ONE batched typed read and slices their text
|
|
637
|
+
client-side by each segment's char ranges. Replaces the retired
|
|
638
|
+
second-decomp-manifest positional join (C4/C14's shared-skeleton model).
|
|
639
|
+
"""
|
|
640
|
+
transcript_ids = sorted({ts["transcript"] for s in segments
|
|
641
|
+
for ts in s.text_slices if ts.get("transcript")})
|
|
642
|
+
if not transcript_ids:
|
|
643
|
+
return {}
|
|
644
|
+
res = await graph_task(queue, graph_id, "query_nodes",
|
|
645
|
+
query=NodeQuery(ids=transcript_ids).to_dict())
|
|
646
|
+
tnodes: Dict[str, Dict[str, Any]] = {}
|
|
647
|
+
for n in (res.nodes or []):
|
|
648
|
+
d = n.to_dict() if isinstance(n, GraphNode) else dict(n)
|
|
649
|
+
tnodes[d["id"]] = d.get("properties", {}) or {}
|
|
650
|
+
out: Dict[str, Dict[str, str]] = {}
|
|
651
|
+
for s in segments:
|
|
652
|
+
per_t: Dict[str, str] = {}
|
|
653
|
+
for ts in s.text_slices:
|
|
654
|
+
props = tnodes.get(ts.get("transcript"))
|
|
655
|
+
if props is None or ts.get("start") is None or ts.get("end") is None:
|
|
656
|
+
continue
|
|
657
|
+
transcriber = str(props.get("transcriber") or ts["transcript"])
|
|
658
|
+
per_t[transcriber] = str(props.get("text") or "")[int(ts["start"]):int(ts["end"])]
|
|
659
|
+
if per_t:
|
|
660
|
+
out[s.id] = per_t
|
|
661
|
+
return out
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def build_boundary_shift_correction(
|
|
665
|
+
source_id: str, # Source the boundary belongs to
|
|
666
|
+
left_segment_id: str, # Layer-0 Segment LEFT of the boundary (the layer's `boundary_after`)
|
|
667
|
+
right_segment_id: str, # Layer-0 Segment RIGHT of the boundary (recorded; layer re-derives from order)
|
|
668
|
+
text: str, # The exact text moved across the boundary (verbatim, whitespace included)
|
|
669
|
+
direction: str, # "push" (left tail -> right head) | "pull" (the mirror)
|
|
670
|
+
session_id: str, # Owning session id
|
|
671
|
+
supersedes_id: Optional[str] = None, # Prior Correction this one replaces (re-edit)
|
|
672
|
+
actor: str = "human", # Actor
|
|
673
|
+
rationale: Optional[str] = None, # Optional note
|
|
674
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: # (correction node dict, edge dicts)
|
|
675
|
+
"""Build a grouping Correction that moves text across one segment boundary.
|
|
676
|
+
|
|
677
|
+
The FA-misassignment gesture (an ALIGNMENT error, not a transcription
|
|
678
|
+
error): text sits on the wrong side of a boundary; 1:1 audio alignment is
|
|
679
|
+
preserved (segment count and positions never change). The payload carries
|
|
680
|
+
the layer's `boundary_shift` op vocabulary; the layer validates the moved
|
|
681
|
+
text VERBATIM at projection time and fails loudly on mismatch. CORRECTS
|
|
682
|
+
edges anchor BOTH boundary segments.
|
|
683
|
+
"""
|
|
684
|
+
if direction not in ("push", "pull"):
|
|
685
|
+
raise ValueError(f"boundary-shift direction must be 'push' or 'pull', got {direction!r}")
|
|
686
|
+
payload = {"operation": "shift_boundary", "source_id": source_id,
|
|
687
|
+
"boundary_after": left_segment_id, "right_segment_id": right_segment_id,
|
|
688
|
+
"text": text, "direction": direction}
|
|
689
|
+
node = build_correction_node("grouping", session_id, payload, actor=actor,
|
|
690
|
+
rationale=rationale).to_graph_node()
|
|
691
|
+
edges = [make_edge(node.id, left_segment_id, CorrectionRelations.CORRECTS),
|
|
692
|
+
make_edge(node.id, right_segment_id, CorrectionRelations.CORRECTS)]
|
|
693
|
+
if supersedes_id:
|
|
694
|
+
edges.append(make_edge(node.id, supersedes_id, CorrectionRelations.SUPERSEDES))
|
|
695
|
+
return node.to_dict(), edges
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def build_reject_review(
|
|
699
|
+
source_id: str, # Source the rejected correction belongs to (source-scoped loads)
|
|
700
|
+
rejected_id: str, # The prior Correction (typically a proposal) being rejected
|
|
701
|
+
session_id: str, # Owning session id
|
|
702
|
+
actor: str = "human", # Actor (the reviewer)
|
|
703
|
+
rationale: Optional[str] = None, # Optional note (why it was rejected)
|
|
704
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: # (review node dict, edge dicts)
|
|
705
|
+
"""Build a review Correction that REJECTS a prior correction (reject-as-supersede).
|
|
706
|
+
|
|
707
|
+
An append-only VERDICT: rejection is a SUPERSEDES edge from a small
|
|
708
|
+
`review` node, never a status mutation — `resolve_active` then excludes
|
|
709
|
+
the rejected correction with the machinery supersession already uses, and
|
|
710
|
+
the verdict carries actor/rationale/timestamp. A review node maps to NO
|
|
711
|
+
spine edit (`corrections_to_edits` has no arm for it) so it can never
|
|
712
|
+
touch the effective view itself. The ACCEPT verdict is deferred until
|
|
713
|
+
agents actually propose; it rides this same node shape.
|
|
714
|
+
"""
|
|
715
|
+
payload = {"operation": "reject", "source_id": source_id, "rejected_id": rejected_id}
|
|
716
|
+
node = build_correction_node("review", session_id, payload, actor=actor,
|
|
717
|
+
rationale=rationale).to_graph_node()
|
|
718
|
+
edges = [make_edge(node.id, rejected_id, CorrectionRelations.SUPERSEDES)]
|
|
719
|
+
return node.to_dict(), edges
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
async def commit_boundary_shift_correction(
|
|
723
|
+
queue: JobQueue, # Started job queue
|
|
724
|
+
graph_id: str, # Graph-storage capability id
|
|
725
|
+
source_id: str, # Source the boundary belongs to
|
|
726
|
+
left_segment_id: str, # Segment LEFT of the boundary
|
|
727
|
+
right_segment_id: str, # Segment RIGHT of the boundary
|
|
728
|
+
text: str, # Verbatim text moved across the boundary
|
|
729
|
+
direction: str, # "push" | "pull"
|
|
730
|
+
session_id: str, # Owning session id
|
|
731
|
+
supersedes_id: Optional[str] = None, # Prior Correction to supersede (re-edit)
|
|
732
|
+
actor: str = "human", # Actor
|
|
733
|
+
) -> str: # The new Correction node id
|
|
734
|
+
"""Commit a boundary-shift correction (node + CORRECTS x2 [+ SUPERSEDES]) + REVIEWED markers on both segments."""
|
|
735
|
+
node, edges = build_boundary_shift_correction(
|
|
736
|
+
source_id, left_segment_id, right_segment_id, text, direction, session_id,
|
|
737
|
+
supersedes_id=supersedes_id, actor=actor)
|
|
738
|
+
await commit_nodes_edges(queue, graph_id, [node], edges)
|
|
739
|
+
await record_review_markers(queue, graph_id, session_id,
|
|
740
|
+
[(left_segment_id, "corrected"), (right_segment_id, "corrected")])
|
|
741
|
+
return node["id"]
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def build_prune_amendment(
|
|
745
|
+
prior: Dict[str, Any], # The ACTIVE prune Correction being amended (property dict + id)
|
|
746
|
+
unprune_ids: List[str], # Segment ids to REMOVE from the prune set (rescued positions)
|
|
747
|
+
session_id: str, # Owning session id
|
|
748
|
+
actor: str = "human", # Actor
|
|
749
|
+
rationale: Optional[str] = None, # Optional note
|
|
750
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: # (correction node dict, edge dicts)
|
|
751
|
+
"""Build a grouping Correction that supersedes a prune with a REDUCED set (unprune).
|
|
752
|
+
|
|
753
|
+
The append-only inverse of build_prune_correction for single positions: a
|
|
754
|
+
boundary shift that gives text to a pruned segment RESCUES it (the
|
|
755
|
+
falsified-D14 class), and the segment must leave the prune set or the
|
|
756
|
+
layer's projection drops it — WITH its new text — from the effective view.
|
|
757
|
+
The amendment re-carries the prior payload minus the rescued ids plus a
|
|
758
|
+
SUPERSEDES edge; DERIVED_FROM edges re-anchor the still-pruned ids.
|
|
759
|
+
"""
|
|
760
|
+
prior_payload = dict(prior.get("payload") or {})
|
|
761
|
+
remaining = [sid for sid in (prior_payload.get("pruned_segment_ids") or [])
|
|
762
|
+
if sid not in set(unprune_ids)]
|
|
763
|
+
payload = {"operation": "prune_empty", "source_id": prior_payload.get("source_id"),
|
|
764
|
+
"pruned_segment_ids": remaining, "pruned_count": len(remaining)}
|
|
765
|
+
node = build_correction_node("grouping", session_id, payload, actor=actor,
|
|
766
|
+
rationale=rationale).to_graph_node()
|
|
767
|
+
edges = [make_edge(node.id, sid, CorrectionRelations.DERIVED_FROM) for sid in remaining]
|
|
768
|
+
edges.append(make_edge(node.id, prior["id"], CorrectionRelations.SUPERSEDES))
|
|
769
|
+
return node.to_dict(), edges
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
async def commit_prune_amendment(
|
|
773
|
+
queue: JobQueue, # Started job queue
|
|
774
|
+
graph_id: str, # Graph-storage capability id
|
|
775
|
+
prior: Dict[str, Any], # The ACTIVE prune Correction being amended
|
|
776
|
+
unprune_ids: List[str], # Segment ids rescued from the prune set
|
|
777
|
+
session_id: str, # Owning session id
|
|
778
|
+
actor: str = "human", # Actor
|
|
779
|
+
) -> Dict[str, Any]: # The amended correction as a property dict + id (local-echo ready)
|
|
780
|
+
"""Commit an unprune amendment (node + DERIVED_FROM edges + SUPERSEDES)."""
|
|
781
|
+
node, edges = build_prune_amendment(prior, unprune_ids, session_id, actor=actor)
|
|
782
|
+
await commit_nodes_edges(queue, graph_id, [node], edges)
|
|
783
|
+
return _node_to_correction_dict(node)
|