cjm-transcript-graph-schema 0.0.6__tar.gz → 0.0.8__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.
- {cjm_transcript_graph_schema-0.0.6 → cjm_transcript_graph_schema-0.0.8}/MANIFEST.in +0 -1
- cjm_transcript_graph_schema-0.0.8/PKG-INFO +52 -0
- cjm_transcript_graph_schema-0.0.8/README.md +35 -0
- cjm_transcript_graph_schema-0.0.8/cjm_transcript_graph_schema/__init__.py +1 -0
- {cjm_transcript_graph_schema-0.0.6 → cjm_transcript_graph_schema-0.0.8}/cjm_transcript_graph_schema/schema.py +104 -27
- cjm_transcript_graph_schema-0.0.8/cjm_transcript_graph_schema.egg-info/PKG-INFO +52 -0
- {cjm_transcript_graph_schema-0.0.6 → cjm_transcript_graph_schema-0.0.8}/cjm_transcript_graph_schema.egg-info/SOURCES.txt +2 -3
- cjm_transcript_graph_schema-0.0.8/cjm_transcript_graph_schema.egg-info/requires.txt +2 -0
- {cjm_transcript_graph_schema-0.0.6 → cjm_transcript_graph_schema-0.0.8}/pyproject.toml +2 -7
- cjm_transcript_graph_schema-0.0.8/tests/test_schema.py +176 -0
- cjm_transcript_graph_schema-0.0.6/PKG-INFO +0 -356
- cjm_transcript_graph_schema-0.0.6/README.md +0 -338
- cjm_transcript_graph_schema-0.0.6/cjm_transcript_graph_schema/__init__.py +0 -1
- cjm_transcript_graph_schema-0.0.6/cjm_transcript_graph_schema/_modidx.py +0 -59
- cjm_transcript_graph_schema-0.0.6/cjm_transcript_graph_schema.egg-info/PKG-INFO +0 -356
- cjm_transcript_graph_schema-0.0.6/cjm_transcript_graph_schema.egg-info/entry_points.txt +0 -2
- cjm_transcript_graph_schema-0.0.6/cjm_transcript_graph_schema.egg-info/requires.txt +0 -2
- {cjm_transcript_graph_schema-0.0.6 → cjm_transcript_graph_schema-0.0.8}/LICENSE +0 -0
- {cjm_transcript_graph_schema-0.0.6 → cjm_transcript_graph_schema-0.0.8}/cjm_transcript_graph_schema.egg-info/dependency_links.txt +0 -0
- {cjm_transcript_graph_schema-0.0.6 → cjm_transcript_graph_schema-0.0.8}/cjm_transcript_graph_schema.egg-info/top_level.txt +0 -0
- {cjm_transcript_graph_schema-0.0.6 → cjm_transcript_graph_schema-0.0.8}/setup.cfg +0 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cjm-transcript-graph-schema
|
|
3
|
+
Version: 0.0.8
|
|
4
|
+
Summary: Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcript, and Segment nodes with deterministic identity tuples and graph-node mapping, shared by the transcription, decomposition, and correction workflow cores.
|
|
5
|
+
Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Repository, https://github.com/cj-mills/cjm-transcript-graph-schema
|
|
8
|
+
Keywords: python
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: cjm-context-graph-layer>=0.0.7
|
|
15
|
+
Requires-Dist: cjm-context-graph-primitives>=0.0.12
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# cjm-transcript-graph-schema
|
|
19
|
+
|
|
20
|
+
<!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
|
|
21
|
+
|
|
22
|
+
Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcript, and Segment nodes with deterministic identity tuples and graph-node mapping, shared by the transcription, decomposition, and correction workflow cores.
|
|
23
|
+
|
|
24
|
+
## Modules
|
|
25
|
+
|
|
26
|
+
- **`cjm_transcript_graph_schema.schema`** — The audio-transcript layer schema (where-graph-begins locked layer schema): Source -> AudioSegment(coarse boundary) -> AudioRendition(model-input: raw | vocals | ...) -> Transcript(per-transcriber variants) emitted by transcription, extended with the fine Segment spine (per-rendition) by decomposition. Deterministic identity tuples per the stage-5 ratified rule; the AudioRendition node lets raw + preprocessed model-inputs of one boundary coexist in one graph. Document from the pre-CR-18 era dissolves into Source.
|
|
27
|
+
|
|
28
|
+
## API
|
|
29
|
+
|
|
30
|
+
### `cjm_transcript_graph_schema.schema`
|
|
31
|
+
|
|
32
|
+
- `AudioRenditionNode` _class_ — A model-input rendition OF an AudioSegment — the materialized 16k-mono WAV
|
|
33
|
+
- `AudioSegmentNode` _class_ — Coarse ~5-min spine member: a BOUNDARY range of the Source (an audio fact),
|
|
34
|
+
- `CollectionNode` _class_ — An intrinsic collection (book, podcast series, lecture course) — the
|
|
35
|
+
- `SegmentNode` _class_ — Fine spine member: one VAD chunk — IMMUTABLE audio range + CORRECTABLE
|
|
36
|
+
- `SourceNode` _class_ — The provenance root: one ingested media file.
|
|
37
|
+
- `TranscriptGraphLabels` _class_ — Node labels of the audio-transcript layer schema.
|
|
38
|
+
- `TranscriptNode` _class_ — One transcriber's text for one AudioRendition (per-transcriber variants at
|
|
39
|
+
- `TranscriptSliceRef` _class_ — One per-transcriber char-range reference for a fine Segment: where this
|
|
40
|
+
- `audio_rendition_node_id` _function_ — AudioRendition identity = (audio segment, preprocessing chain).
|
|
41
|
+
- `audio_segment_node_id` _function_ — AudioSegment identity = (source, boundary range).
|
|
42
|
+
- `collection_edges` _function_ — Membership edges for a collection (ae3464fc: ORDER IS OPTIONAL).
|
|
43
|
+
- `collection_node_id` _function_ — Collection identity = the NORMALIZED TITLE (ratified fork, hub-session
|
|
44
|
+
- `normalize_collection_title` _function_ — Normalize a collection title for identity derivation.
|
|
45
|
+
- `segment_node_id` _function_ — Fine Segment identity = audio-side only (audio rendition, VAD config, chunk
|
|
46
|
+
- `source_node_id` _function_ — Source identity = the ingested file's content hash (Thread-1 ingested-root identity).
|
|
47
|
+
- `transcript_node_id` _function_ — Transcript identity = (audio rendition, transcriber, config) — MIRRORS the
|
|
48
|
+
|
|
49
|
+
## Dependencies
|
|
50
|
+
|
|
51
|
+
**Depends on:** `cjm-context-graph-layer`, `cjm-context-graph-primitives`
|
|
52
|
+
**Used by:** `cjm-context-graph-layer`, `cjm-transcript-correction-core`, `cjm-transcript-correction-tui`, `cjm-transcript-decomp-core`, `cjm-transcription-core`
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# cjm-transcript-graph-schema
|
|
2
|
+
|
|
3
|
+
<!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
|
|
4
|
+
|
|
5
|
+
Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcript, and Segment nodes with deterministic identity tuples and graph-node mapping, shared by the transcription, decomposition, and correction workflow cores.
|
|
6
|
+
|
|
7
|
+
## Modules
|
|
8
|
+
|
|
9
|
+
- **`cjm_transcript_graph_schema.schema`** — The audio-transcript layer schema (where-graph-begins locked layer schema): Source -> AudioSegment(coarse boundary) -> AudioRendition(model-input: raw | vocals | ...) -> Transcript(per-transcriber variants) emitted by transcription, extended with the fine Segment spine (per-rendition) by decomposition. Deterministic identity tuples per the stage-5 ratified rule; the AudioRendition node lets raw + preprocessed model-inputs of one boundary coexist in one graph. Document from the pre-CR-18 era dissolves into Source.
|
|
10
|
+
|
|
11
|
+
## API
|
|
12
|
+
|
|
13
|
+
### `cjm_transcript_graph_schema.schema`
|
|
14
|
+
|
|
15
|
+
- `AudioRenditionNode` _class_ — A model-input rendition OF an AudioSegment — the materialized 16k-mono WAV
|
|
16
|
+
- `AudioSegmentNode` _class_ — Coarse ~5-min spine member: a BOUNDARY range of the Source (an audio fact),
|
|
17
|
+
- `CollectionNode` _class_ — An intrinsic collection (book, podcast series, lecture course) — the
|
|
18
|
+
- `SegmentNode` _class_ — Fine spine member: one VAD chunk — IMMUTABLE audio range + CORRECTABLE
|
|
19
|
+
- `SourceNode` _class_ — The provenance root: one ingested media file.
|
|
20
|
+
- `TranscriptGraphLabels` _class_ — Node labels of the audio-transcript layer schema.
|
|
21
|
+
- `TranscriptNode` _class_ — One transcriber's text for one AudioRendition (per-transcriber variants at
|
|
22
|
+
- `TranscriptSliceRef` _class_ — One per-transcriber char-range reference for a fine Segment: where this
|
|
23
|
+
- `audio_rendition_node_id` _function_ — AudioRendition identity = (audio segment, preprocessing chain).
|
|
24
|
+
- `audio_segment_node_id` _function_ — AudioSegment identity = (source, boundary range).
|
|
25
|
+
- `collection_edges` _function_ — Membership edges for a collection (ae3464fc: ORDER IS OPTIONAL).
|
|
26
|
+
- `collection_node_id` _function_ — Collection identity = the NORMALIZED TITLE (ratified fork, hub-session
|
|
27
|
+
- `normalize_collection_title` _function_ — Normalize a collection title for identity derivation.
|
|
28
|
+
- `segment_node_id` _function_ — Fine Segment identity = audio-side only (audio rendition, VAD config, chunk
|
|
29
|
+
- `source_node_id` _function_ — Source identity = the ingested file's content hash (Thread-1 ingested-root identity).
|
|
30
|
+
- `transcript_node_id` _function_ — Transcript identity = (audio rendition, transcriber, config) — MIRRORS the
|
|
31
|
+
|
|
32
|
+
## Dependencies
|
|
33
|
+
|
|
34
|
+
**Depends on:** `cjm-context-graph-layer`, `cjm-context-graph-primitives`
|
|
35
|
+
**Used by:** `cjm-context-graph-layer`, `cjm-transcript-correction-core`, `cjm-transcript-correction-tui`, `cjm-transcript-decomp-core`, `cjm-transcription-core`
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.8"
|
|
@@ -1,26 +1,17 @@
|
|
|
1
|
-
"""The audio-transcript layer schema (where-graph-begins locked layer schema):
|
|
1
|
+
"""The audio-transcript layer schema (where-graph-begins locked layer schema): Source -> AudioSegment(coarse boundary) -> AudioRendition(model-input: raw | vocals | ...) -> Transcript(per-transcriber variants) emitted by transcription, extended with the fine Segment spine (per-rendition) by decomposition, and with the intrinsic Collection layer ABOVE Source (book/series/course; proposed-by-tool / confirmed-by-actor, order optional) captured at transcription hand-off. Deterministic identity tuples per the stage-5 ratified rule; the AudioRendition node lets raw + preprocessed model-inputs of one boundary coexist in one graph. Document from the pre-CR-18 era dissolves into Source."""
|
|
2
2
|
|
|
3
|
-
Docs: https://cj-mills.github.io/cjm-transcript-graph-schemaschema.html.md"""
|
|
4
|
-
|
|
5
|
-
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/schema.ipynb.
|
|
6
|
-
|
|
7
|
-
# %% auto #0
|
|
8
|
-
__all__ = ['TranscriptGraphLabels', 'source_node_id', 'audio_segment_node_id', 'audio_rendition_node_id', 'transcript_node_id',
|
|
9
|
-
'segment_node_id', 'SourceNode', 'AudioSegmentNode', 'AudioRenditionNode', 'TranscriptNode',
|
|
10
|
-
'TranscriptSliceRef', 'SegmentNode']
|
|
11
|
-
|
|
12
|
-
# %% ../nbs/schema.ipynb #76d0dd1c
|
|
13
|
-
from pathlib import Path
|
|
14
3
|
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
15
5
|
from typing import Any, Dict, List, Optional
|
|
16
6
|
|
|
7
|
+
from cjm_context_graph_layer.grammar import (attribution, make_edge, OverlayRelations, spine_edges,
|
|
8
|
+
SpineRelations)
|
|
17
9
|
from cjm_context_graph_layer.identity import derive_node_id
|
|
18
|
-
from cjm_context_graph_layer.grammar import OverlayRelations, attribution, make_edge
|
|
19
10
|
from cjm_context_graph_primitives.locators import FileRef, GraphNodeRef
|
|
20
|
-
from cjm_context_graph_primitives.slices import TimeSlice, CharSlice, FullContent
|
|
21
11
|
from cjm_context_graph_primitives.provenance import SourceRef
|
|
12
|
+
from cjm_context_graph_primitives.slices import CharSlice, FullContent, TimeSlice
|
|
13
|
+
|
|
22
14
|
|
|
23
|
-
# %% ../nbs/schema.ipynb #ff94773a
|
|
24
15
|
class TranscriptGraphLabels:
|
|
25
16
|
"""Node labels of the audio-transcript layer schema."""
|
|
26
17
|
SOURCE = "Source" # Provenance root: one ingested media file
|
|
@@ -28,20 +19,21 @@ class TranscriptGraphLabels:
|
|
|
28
19
|
AUDIO_RENDITION = "AudioRendition" # A model-input rendition OF an AudioSegment (raw convert | vocals | future enhancement); OWNS the model-input WAV
|
|
29
20
|
TRANSCRIPT = "Transcript" # Per-transcriber text variant of one AudioRendition
|
|
30
21
|
SEGMENT = "Segment" # Fine spine member: one VAD chunk (immutable audio + correctable text), per-rendition
|
|
22
|
+
COLLECTION = "Collection" # Intrinsic collection (book / series / course): the fractal spine layer ABOVE Source
|
|
31
23
|
|
|
32
24
|
@classmethod
|
|
33
25
|
def all(cls) -> list: # All schema labels
|
|
34
26
|
"""All schema labels."""
|
|
35
|
-
return [cls.SOURCE, cls.AUDIO_SEGMENT, cls.AUDIO_RENDITION, cls.TRANSCRIPT, cls.SEGMENT]
|
|
27
|
+
return [cls.SOURCE, cls.AUDIO_SEGMENT, cls.AUDIO_RENDITION, cls.TRANSCRIPT, cls.SEGMENT, cls.COLLECTION]
|
|
28
|
+
|
|
36
29
|
|
|
37
|
-
# %% ../nbs/schema.ipynb #a5ea12db
|
|
38
30
|
def source_node_id(
|
|
39
31
|
content_hash: str, # Content hash of the source media file ("algo:hexdigest")
|
|
40
32
|
) -> str: # Deterministic Source node id
|
|
41
33
|
"""Source identity = the ingested file's content hash (Thread-1 ingested-root identity)."""
|
|
42
34
|
return derive_node_id("source", content_hash)
|
|
43
35
|
|
|
44
|
-
|
|
36
|
+
|
|
45
37
|
def audio_segment_node_id(
|
|
46
38
|
source_id: str, # Owning Source node id
|
|
47
39
|
start: float, # Boundary start (source-coordinate seconds)
|
|
@@ -55,7 +47,7 @@ def audio_segment_node_id(
|
|
|
55
47
|
the id; the model-input WAV lives on the `AudioRendition` child, not here."""
|
|
56
48
|
return derive_node_id("audio-segment", source_id, start, end)
|
|
57
49
|
|
|
58
|
-
|
|
50
|
+
|
|
59
51
|
def audio_rendition_node_id(
|
|
60
52
|
audio_segment_id: str, # Owning AudioSegment node id
|
|
61
53
|
chain: List[str], # Ordered preprocessing-chain descriptors; [] = the raw convert-only rendition
|
|
@@ -73,7 +65,7 @@ def audio_rendition_node_id(
|
|
|
73
65
|
search)."""
|
|
74
66
|
return derive_node_id("audio-rendition", audio_segment_id, *chain)
|
|
75
67
|
|
|
76
|
-
|
|
68
|
+
|
|
77
69
|
def transcript_node_id(
|
|
78
70
|
rendition_id: str, # Owning AudioRendition node id
|
|
79
71
|
transcriber: str, # Transcriber capability name (e.g. "cjm-transcription-plugin-whisper")
|
|
@@ -85,7 +77,7 @@ def transcript_node_id(
|
|
|
85
77
|
RENDITION, so a raw vs vocals transcript of the same boundary are distinct."""
|
|
86
78
|
return derive_node_id("transcript", rendition_id, transcriber, config_hash)
|
|
87
79
|
|
|
88
|
-
|
|
80
|
+
|
|
89
81
|
def segment_node_id(
|
|
90
82
|
rendition_id: str, # Owning AudioRendition node id
|
|
91
83
|
vad_config_hash: str, # VAD capability config hash (skeleton identity input)
|
|
@@ -99,7 +91,7 @@ def segment_node_id(
|
|
|
99
91
|
yield different VAD chunk boundaries than raw)."""
|
|
100
92
|
return derive_node_id("segment", rendition_id, vad_config_hash, chunk_start, chunk_end)
|
|
101
93
|
|
|
102
|
-
|
|
94
|
+
|
|
103
95
|
@dataclass
|
|
104
96
|
class SourceNode:
|
|
105
97
|
"""The provenance root: one ingested media file."""
|
|
@@ -128,7 +120,7 @@ class SourceNode:
|
|
|
128
120
|
content_hash=self.content_hash).to_dict()],
|
|
129
121
|
}
|
|
130
122
|
|
|
131
|
-
|
|
123
|
+
|
|
132
124
|
@dataclass
|
|
133
125
|
class AudioSegmentNode:
|
|
134
126
|
"""Coarse ~5-min spine member: a BOUNDARY range of the Source (an audio fact),
|
|
@@ -169,7 +161,7 @@ class AudioSegmentNode:
|
|
|
169
161
|
"sources": [],
|
|
170
162
|
}
|
|
171
163
|
|
|
172
|
-
|
|
164
|
+
|
|
173
165
|
@dataclass
|
|
174
166
|
class AudioRenditionNode:
|
|
175
167
|
"""A model-input rendition OF an AudioSegment — the materialized 16k-mono WAV
|
|
@@ -216,7 +208,7 @@ class AudioRenditionNode:
|
|
|
216
208
|
"""DERIVED_FROM edge: this rendition derives from its AudioSegment's audio."""
|
|
217
209
|
return make_edge(self.id, self.audio_segment, OverlayRelations.DERIVED_FROM)
|
|
218
210
|
|
|
219
|
-
|
|
211
|
+
|
|
220
212
|
@dataclass
|
|
221
213
|
class TranscriptNode:
|
|
222
214
|
"""One transcriber's text for one AudioRendition (per-transcriber variants at
|
|
@@ -259,7 +251,7 @@ class TranscriptNode:
|
|
|
259
251
|
"""DERIVED_FROM edge: this Transcript derives from its AudioRendition."""
|
|
260
252
|
return make_edge(self.id, self.rendition, OverlayRelations.DERIVED_FROM)
|
|
261
253
|
|
|
262
|
-
|
|
254
|
+
|
|
263
255
|
@dataclass
|
|
264
256
|
class TranscriptSliceRef:
|
|
265
257
|
"""One per-transcriber char-range reference for a fine Segment: where this
|
|
@@ -276,7 +268,7 @@ class TranscriptSliceRef:
|
|
|
276
268
|
content_hash=SourceRef.compute_hash(self.text.encode()),
|
|
277
269
|
slice=CharSlice(start=self.start_char, end=self.end_char)).to_dict()
|
|
278
270
|
|
|
279
|
-
|
|
271
|
+
|
|
280
272
|
@dataclass
|
|
281
273
|
class SegmentNode:
|
|
282
274
|
"""Fine spine member: one VAD chunk — IMMUTABLE audio range + CORRECTABLE
|
|
@@ -331,3 +323,88 @@ class SegmentNode:
|
|
|
331
323
|
"properties": props,
|
|
332
324
|
"sources": sources,
|
|
333
325
|
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def normalize_collection_title(
|
|
329
|
+
title: str, # Raw title (folder name, user input, ...)
|
|
330
|
+
) -> str: # Normalized identity form
|
|
331
|
+
"""Normalize a collection title for identity derivation.
|
|
332
|
+
|
|
333
|
+
Casefold + underscores-to-spaces + whitespace collapse, so a folder named
|
|
334
|
+
``Hardcore_History`` and a hand-typed ``Hardcore History`` converge on the
|
|
335
|
+
SAME collection node (ae3464fc: select-existing and retype-the-title are
|
|
336
|
+
the same act by construction). Display keeps the raw title; only identity
|
|
337
|
+
normalizes."""
|
|
338
|
+
return " ".join(title.replace("_", " ").split()).casefold()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def collection_node_id(
|
|
342
|
+
title: str, # Collection title (normalized internally)
|
|
343
|
+
) -> str: # Deterministic Collection node id
|
|
344
|
+
"""Collection identity = the NORMALIZED TITLE (ratified fork, hub-session
|
|
345
|
+
2026-07-21): path-based identity breaks workspace relocatability, and
|
|
346
|
+
member-hash identity breaks late-added members — late binding is a feature.
|
|
347
|
+
The same book ingested twice (different paths, different machines, members
|
|
348
|
+
accreted over weeks) is the same collection; rare title collisions are
|
|
349
|
+
propose/confirm reconciler territory."""
|
|
350
|
+
return derive_node_id("collection", normalize_collection_title(title))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@dataclass
|
|
354
|
+
class CollectionNode:
|
|
355
|
+
"""An intrinsic collection (book, podcast series, lecture course) — the
|
|
356
|
+
fractal spine layer ABOVE Source (the grammar's "series -> episode
|
|
357
|
+
tomorrow", arrived).
|
|
358
|
+
|
|
359
|
+
Status rides the node (ae3464fc actor criterion): a human who accepted,
|
|
360
|
+
renamed, or selected the collection at run launch has CONFIRMED it;
|
|
361
|
+
untouched tool defaults (headless folder run) land PROPOSED — visible and
|
|
362
|
+
grouping-usable everywhere, flagged as awaiting a human. A folder is
|
|
363
|
+
EVIDENCE of a grouping, not proof: proposed collections are cheap to
|
|
364
|
+
rename, refile out of, or dissolve. root_kind=asserted — a collection is
|
|
365
|
+
a grouping ASSERTION about ingested content, not ingested content itself.
|
|
366
|
+
Collection PART_OF Collection nesting is grammar-free (no v0 tooling)."""
|
|
367
|
+
title: str # Display title (identity input via normalize_collection_title)
|
|
368
|
+
status: str = "proposed" # "proposed" (tool inference) | "confirmed" (human act)
|
|
369
|
+
actor: str = "human" # Who declared it (attribution; e.g. "human", "tui:transcription", "cli:transcribe")
|
|
370
|
+
asserted_at: Optional[float] = None # Declaration timestamp; None = now
|
|
371
|
+
|
|
372
|
+
@property
|
|
373
|
+
def id(self) -> str: # Deterministic node id
|
|
374
|
+
"""Deterministic node id."""
|
|
375
|
+
return collection_node_id(self.title)
|
|
376
|
+
|
|
377
|
+
def to_graph_node(self) -> Dict[str, Any]: # Node wire dict
|
|
378
|
+
"""Build the Collection node wire dict (asserted root; attribution included)."""
|
|
379
|
+
props: Dict[str, Any] = {
|
|
380
|
+
"title": self.title,
|
|
381
|
+
"status": self.status,
|
|
382
|
+
"root_kind": "asserted",
|
|
383
|
+
}
|
|
384
|
+
props.update(attribution(self.actor, method="collection-declaration",
|
|
385
|
+
asserted_at=self.asserted_at))
|
|
386
|
+
return {
|
|
387
|
+
"id": self.id,
|
|
388
|
+
"label": TranscriptGraphLabels.COLLECTION,
|
|
389
|
+
"properties": props,
|
|
390
|
+
"sources": [],
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def collection_edges(
|
|
395
|
+
collection_id: str, # Collection node id
|
|
396
|
+
member_ids: List[str], # Member Source node ids (declaration order when ordered=True)
|
|
397
|
+
ordered: bool = False, # True = materialize the order (NEXT chain + STARTS_WITH)
|
|
398
|
+
) -> List[Dict[str, Any]]: # Edge wire dicts
|
|
399
|
+
"""Membership edges for a collection (ae3464fc: ORDER IS OPTIONAL).
|
|
400
|
+
|
|
401
|
+
Unordered membership is just PART_OF member->collection — individually
|
|
402
|
+
accreted members (chapters run one at a time) attach without fabricating a
|
|
403
|
+
sequence. When an order IS known (a folder run's sorted expansion, or a
|
|
404
|
+
curation act), the full fractal spine pattern materializes via
|
|
405
|
+
spine_edges: PART_OF + NEXT chain + STARTS_WITH — identical grammar to
|
|
406
|
+
every layer below. Reordering and late insertion are curation ops, never
|
|
407
|
+
capture-time guesses."""
|
|
408
|
+
if ordered:
|
|
409
|
+
return spine_edges(collection_id, member_ids)
|
|
410
|
+
return [make_edge(m, collection_id, SpineRelations.PART_OF) for m in member_ids]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cjm-transcript-graph-schema
|
|
3
|
+
Version: 0.0.8
|
|
4
|
+
Summary: Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcript, and Segment nodes with deterministic identity tuples and graph-node mapping, shared by the transcription, decomposition, and correction workflow cores.
|
|
5
|
+
Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Repository, https://github.com/cj-mills/cjm-transcript-graph-schema
|
|
8
|
+
Keywords: python
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: cjm-context-graph-layer>=0.0.7
|
|
15
|
+
Requires-Dist: cjm-context-graph-primitives>=0.0.12
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# cjm-transcript-graph-schema
|
|
19
|
+
|
|
20
|
+
<!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
|
|
21
|
+
|
|
22
|
+
Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcript, and Segment nodes with deterministic identity tuples and graph-node mapping, shared by the transcription, decomposition, and correction workflow cores.
|
|
23
|
+
|
|
24
|
+
## Modules
|
|
25
|
+
|
|
26
|
+
- **`cjm_transcript_graph_schema.schema`** — The audio-transcript layer schema (where-graph-begins locked layer schema): Source -> AudioSegment(coarse boundary) -> AudioRendition(model-input: raw | vocals | ...) -> Transcript(per-transcriber variants) emitted by transcription, extended with the fine Segment spine (per-rendition) by decomposition. Deterministic identity tuples per the stage-5 ratified rule; the AudioRendition node lets raw + preprocessed model-inputs of one boundary coexist in one graph. Document from the pre-CR-18 era dissolves into Source.
|
|
27
|
+
|
|
28
|
+
## API
|
|
29
|
+
|
|
30
|
+
### `cjm_transcript_graph_schema.schema`
|
|
31
|
+
|
|
32
|
+
- `AudioRenditionNode` _class_ — A model-input rendition OF an AudioSegment — the materialized 16k-mono WAV
|
|
33
|
+
- `AudioSegmentNode` _class_ — Coarse ~5-min spine member: a BOUNDARY range of the Source (an audio fact),
|
|
34
|
+
- `CollectionNode` _class_ — An intrinsic collection (book, podcast series, lecture course) — the
|
|
35
|
+
- `SegmentNode` _class_ — Fine spine member: one VAD chunk — IMMUTABLE audio range + CORRECTABLE
|
|
36
|
+
- `SourceNode` _class_ — The provenance root: one ingested media file.
|
|
37
|
+
- `TranscriptGraphLabels` _class_ — Node labels of the audio-transcript layer schema.
|
|
38
|
+
- `TranscriptNode` _class_ — One transcriber's text for one AudioRendition (per-transcriber variants at
|
|
39
|
+
- `TranscriptSliceRef` _class_ — One per-transcriber char-range reference for a fine Segment: where this
|
|
40
|
+
- `audio_rendition_node_id` _function_ — AudioRendition identity = (audio segment, preprocessing chain).
|
|
41
|
+
- `audio_segment_node_id` _function_ — AudioSegment identity = (source, boundary range).
|
|
42
|
+
- `collection_edges` _function_ — Membership edges for a collection (ae3464fc: ORDER IS OPTIONAL).
|
|
43
|
+
- `collection_node_id` _function_ — Collection identity = the NORMALIZED TITLE (ratified fork, hub-session
|
|
44
|
+
- `normalize_collection_title` _function_ — Normalize a collection title for identity derivation.
|
|
45
|
+
- `segment_node_id` _function_ — Fine Segment identity = audio-side only (audio rendition, VAD config, chunk
|
|
46
|
+
- `source_node_id` _function_ — Source identity = the ingested file's content hash (Thread-1 ingested-root identity).
|
|
47
|
+
- `transcript_node_id` _function_ — Transcript identity = (audio rendition, transcriber, config) — MIRRORS the
|
|
48
|
+
|
|
49
|
+
## Dependencies
|
|
50
|
+
|
|
51
|
+
**Depends on:** `cjm-context-graph-layer`, `cjm-context-graph-primitives`
|
|
52
|
+
**Used by:** `cjm-context-graph-layer`, `cjm-transcript-correction-core`, `cjm-transcript-correction-tui`, `cjm-transcript-decomp-core`, `cjm-transcription-core`
|
|
@@ -3,11 +3,10 @@ MANIFEST.in
|
|
|
3
3
|
README.md
|
|
4
4
|
pyproject.toml
|
|
5
5
|
cjm_transcript_graph_schema/__init__.py
|
|
6
|
-
cjm_transcript_graph_schema/_modidx.py
|
|
7
6
|
cjm_transcript_graph_schema/schema.py
|
|
8
7
|
cjm_transcript_graph_schema.egg-info/PKG-INFO
|
|
9
8
|
cjm_transcript_graph_schema.egg-info/SOURCES.txt
|
|
10
9
|
cjm_transcript_graph_schema.egg-info/dependency_links.txt
|
|
11
|
-
cjm_transcript_graph_schema.egg-info/entry_points.txt
|
|
12
10
|
cjm_transcript_graph_schema.egg-info/requires.txt
|
|
13
|
-
cjm_transcript_graph_schema.egg-info/top_level.txt
|
|
11
|
+
cjm_transcript_graph_schema.egg-info/top_level.txt
|
|
12
|
+
tests/test_schema.py
|
|
@@ -10,19 +10,15 @@ readme = "README.md"
|
|
|
10
10
|
requires-python = ">=3.12"
|
|
11
11
|
license = {text = "Apache-2.0"}
|
|
12
12
|
authors = [{name = "Christian J. Mills", email = "9126128+cj-mills@users.noreply.github.com"}]
|
|
13
|
-
keywords = ['
|
|
13
|
+
keywords = ['python']
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
16
16
|
"Programming Language :: Python :: 3 :: Only",
|
|
17
17
|
]
|
|
18
|
-
dependencies = ['cjm-context-graph-layer>=0.0.
|
|
18
|
+
dependencies = ['cjm-context-graph-layer>=0.0.7', 'cjm-context-graph-primitives>=0.0.12']
|
|
19
19
|
|
|
20
20
|
[project.urls]
|
|
21
21
|
Repository = "https://github.com/cj-mills/cjm-transcript-graph-schema"
|
|
22
|
-
Documentation = "https://cj-mills.github.io/cjm-transcript-graph-schema/"
|
|
23
|
-
|
|
24
|
-
[project.entry-points.nbdev]
|
|
25
|
-
cjm_transcript_graph_schema = "cjm_transcript_graph_schema._modidx:d"
|
|
26
22
|
|
|
27
23
|
[tool.setuptools.dynamic]
|
|
28
24
|
version = {attr = "cjm_transcript_graph_schema.__version__"}
|
|
@@ -30,4 +26,3 @@ version = {attr = "cjm_transcript_graph_schema.__version__"}
|
|
|
30
26
|
[tool.setuptools.packages.find]
|
|
31
27
|
include = ["cjm_transcript_graph_schema*"]
|
|
32
28
|
|
|
33
|
-
[tool.nbdev]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Tests for cjm_transcript_graph_schema.schema — identity tuples + wire shapes.
|
|
2
|
+
|
|
3
|
+
Projected from the schema notebook's four test cells at the c25780e8 flip."""
|
|
4
|
+
from cjm_context_graph_primitives.provenance import SourceRef
|
|
5
|
+
from cjm_transcript_graph_schema.schema import (AudioRenditionNode, AudioSegmentNode,
|
|
6
|
+
CollectionNode, SegmentNode, SourceNode,
|
|
7
|
+
TranscriptNode, TranscriptSliceRef,
|
|
8
|
+
audio_rendition_node_id,
|
|
9
|
+
audio_segment_node_id,
|
|
10
|
+
collection_edges, collection_node_id,
|
|
11
|
+
normalize_collection_title,
|
|
12
|
+
segment_node_id, source_node_id,
|
|
13
|
+
transcript_node_id)
|
|
14
|
+
|
|
15
|
+
SID = source_node_id("sha256:abc")
|
|
16
|
+
A1 = audio_segment_node_id(SID, 0.0, 300.2)
|
|
17
|
+
R_RAW = audio_rendition_node_id(A1, [])
|
|
18
|
+
R_VOX = audio_rendition_node_id(A1, ["source_separation:demucs@cfg"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_identity_determinism_and_cross_type_distinctness():
|
|
22
|
+
assert SID == source_node_id("sha256:abc")
|
|
23
|
+
assert A1 == audio_segment_node_id(SID, 0.0, 300.2)
|
|
24
|
+
assert A1 != audio_segment_node_id(SID, 0.0, 300.3)
|
|
25
|
+
# renditions: empty chain (raw) is stable + distinct from any preprocessing chain
|
|
26
|
+
assert R_RAW == audio_rendition_node_id(A1, [])
|
|
27
|
+
assert R_VOX != R_RAW, "vocals rendition is a distinct node from raw"
|
|
28
|
+
assert R_RAW != A1, "the raw rendition is its own node, not the audio segment"
|
|
29
|
+
assert audio_rendition_node_id(A1, ["a", "b"]) != audio_rendition_node_id(A1, ["b", "a"]), \
|
|
30
|
+
"chain order matters"
|
|
31
|
+
# transcript / segment key on the RENDITION now
|
|
32
|
+
t1 = transcript_node_id(R_RAW, "whisper", "cfg1")
|
|
33
|
+
assert t1 != transcript_node_id(R_RAW, "voxtral", "cfg1")
|
|
34
|
+
assert t1 != transcript_node_id(R_RAW, "whisper", "cfg2")
|
|
35
|
+
assert t1 != transcript_node_id(R_VOX, "whisper", "cfg1"), "raw vs vocals transcript distinct"
|
|
36
|
+
s1 = segment_node_id(R_RAW, "vadcfg", 1.5, 4.25)
|
|
37
|
+
assert s1 == segment_node_id(R_RAW, "vadcfg", 1.5, 4.25)
|
|
38
|
+
assert s1 != segment_node_id(R_VOX, "vadcfg", 1.5, 4.25), "raw vs vocals fine spine distinct"
|
|
39
|
+
assert len({SID, A1, R_RAW, R_VOX, t1, s1}) == 6, "kinds never collide"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_source_audio_segment_rendition_wire_shapes():
|
|
43
|
+
src = SourceNode(content_hash="sha256:abc", path="/media/ep1.mp3")
|
|
44
|
+
n = src.to_graph_node()
|
|
45
|
+
assert n["label"] == "Source" and n["id"] == source_node_id("sha256:abc")
|
|
46
|
+
assert n["properties"]["title"] == "ep1" and n["properties"]["root_kind"] == "ingested"
|
|
47
|
+
assert n["sources"][0]["content_hash"] == "sha256:abc"
|
|
48
|
+
assert SourceNode(content_hash="sha256:abc", path="/x.mp3",
|
|
49
|
+
title="Custom").to_graph_node()["properties"]["title"] == "Custom"
|
|
50
|
+
|
|
51
|
+
# AudioSegment is a hashless boundary now (model-input moved to the rendition)
|
|
52
|
+
aseg = AudioSegmentNode(source=src.id, index=0, start=0.0, end=300.2,
|
|
53
|
+
segment_path="/cuts/seg0.mp3")
|
|
54
|
+
an = aseg.to_graph_node()
|
|
55
|
+
assert an["id"] == audio_segment_node_id(src.id, 0.0, 300.2)
|
|
56
|
+
assert an["label"] == "AudioSegment"
|
|
57
|
+
assert an["properties"]["source_id"] == src.id
|
|
58
|
+
assert an["properties"]["segment_path"] == "/cuts/seg0.mp3"
|
|
59
|
+
assert "model_input_path" not in an["properties"], "model-input lives on the rendition now"
|
|
60
|
+
assert an["sources"] == [], "boundary node is hashless"
|
|
61
|
+
|
|
62
|
+
# AudioRendition owns the model-input; raw vs vocals coexist under one AudioSegment
|
|
63
|
+
raw = AudioRenditionNode(audio_segment=aseg.id, model_input_path="/cache/seg0.wav",
|
|
64
|
+
model_input_hash="sha256:wav0")
|
|
65
|
+
rn = raw.to_graph_node()
|
|
66
|
+
assert rn["id"] == audio_rendition_node_id(aseg.id, []) and rn["label"] == "AudioRendition"
|
|
67
|
+
assert rn["properties"]["audio_segment_id"] == aseg.id
|
|
68
|
+
assert rn["properties"]["is_raw"] is True
|
|
69
|
+
assert rn["properties"]["chain"] == [] and "preprocessing" not in rn["properties"]
|
|
70
|
+
assert rn["sources"][0]["content_hash"] == "sha256:wav0"
|
|
71
|
+
re = raw.derived_edge()
|
|
72
|
+
assert re["relation_type"] == "DERIVED_FROM"
|
|
73
|
+
assert re["source_id"] == rn["id"] and re["target_id"] == aseg.id
|
|
74
|
+
|
|
75
|
+
vox = AudioRenditionNode(audio_segment=aseg.id, model_input_path="/cache/seg0_vocals.wav",
|
|
76
|
+
model_input_hash="sha256:wav0vox",
|
|
77
|
+
chain=["source_separation:demucs@cfg"])
|
|
78
|
+
vn = vox.to_graph_node()
|
|
79
|
+
assert vn["id"] != rn["id"], "vocals rendition coexists as a distinct node"
|
|
80
|
+
assert vn["properties"]["is_raw"] is False
|
|
81
|
+
assert vn["properties"]["preprocessing"] == "source_separation:demucs@cfg"
|
|
82
|
+
assert vn["sources"][0]["content_hash"] == "sha256:wav0vox"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_transcript_shape_attribution_and_derived_edge():
|
|
86
|
+
t = TranscriptNode(rendition=R_RAW, transcriber="whisper", config_hash="cfg1",
|
|
87
|
+
text="hello world", audio_hash="sha256:wav0",
|
|
88
|
+
metadata={"model": "base"}, asserted_at=123.0)
|
|
89
|
+
tn = t.to_graph_node()
|
|
90
|
+
assert tn["id"] == transcript_node_id(R_RAW, "whisper", "cfg1")
|
|
91
|
+
assert tn["properties"]["actor"] == "capability:whisper"
|
|
92
|
+
assert tn["properties"]["method"] == "transcribe"
|
|
93
|
+
assert tn["properties"]["asserted_at"] == 123.0
|
|
94
|
+
assert tn["properties"]["text"] == "hello world"
|
|
95
|
+
assert tn["properties"]["metadata"] == {"model": "base"}
|
|
96
|
+
assert tn["properties"]["rendition_id"] == R_RAW
|
|
97
|
+
assert tn["sources"][0]["slice"]["kind"] == "full"
|
|
98
|
+
e = t.derived_edge()
|
|
99
|
+
assert e["relation_type"] == "DERIVED_FROM"
|
|
100
|
+
assert e["source_id"] == tn["id"] and e["target_id"] == R_RAW
|
|
101
|
+
assert e["id"] == t.derived_edge()["id"], "deterministic edge id"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_segment_shape_slices_identity_and_empty():
|
|
105
|
+
acc = TranscriptSliceRef(transcript="t-acc", start_char=0, end_char=11, text="hello world")
|
|
106
|
+
var = TranscriptSliceRef(transcript="t-lw", start_char=0, end_char=10, text="helo world")
|
|
107
|
+
seg = SegmentNode(rendition=R_RAW, vad_config_hash="vadcfg", chunk_start=1.5, chunk_end=4.25,
|
|
108
|
+
index=7, start_time=101.5, end_time=104.25, text="hello world",
|
|
109
|
+
audio_hash="sha256:wav0", source=SID, text_from="t-acc",
|
|
110
|
+
text_slices=[acc, var])
|
|
111
|
+
sn = seg.to_graph_node()
|
|
112
|
+
assert sn["id"] == segment_node_id(R_RAW, "vadcfg", 1.5, 4.25)
|
|
113
|
+
assert sn["properties"]["text"] == "hello world" and sn["properties"]["text_from"] == "t-acc"
|
|
114
|
+
assert sn["properties"]["source_id"] == SID and sn["properties"]["rendition_id"] == R_RAW
|
|
115
|
+
assert len(sn["sources"]) == 3
|
|
116
|
+
audio_ref, acc_ref, var_ref = sn["sources"]
|
|
117
|
+
assert audio_ref["slice"]["kind"] == "time" and audio_ref["slice"]["start"] == 1.5
|
|
118
|
+
assert audio_ref["locator"]["node_id"] == R_RAW, "audio ref points at the rendition"
|
|
119
|
+
assert acc_ref["slice"]["kind"] == "char"
|
|
120
|
+
assert acc_ref["content_hash"] == SourceRef.compute_hash(b"hello world")
|
|
121
|
+
assert var_ref["content_hash"] == SourceRef.compute_hash(b"helo world")
|
|
122
|
+
|
|
123
|
+
# identity is audio-side: text changes do NOT change the id
|
|
124
|
+
seg2 = SegmentNode(rendition=R_RAW, vad_config_hash="vadcfg", chunk_start=1.5, chunk_end=4.25,
|
|
125
|
+
index=7, start_time=101.5, end_time=104.25, text="different text")
|
|
126
|
+
assert seg2.id == seg.id
|
|
127
|
+
|
|
128
|
+
# empty (D14-class) segment: audio ref only, no text_from
|
|
129
|
+
empty = SegmentNode(rendition=R_RAW, vad_config_hash="vadcfg", chunk_start=9.0, chunk_end=9.5,
|
|
130
|
+
index=8, start_time=109.0, end_time=109.5, audio_hash="sha256:wav0")
|
|
131
|
+
en = empty.to_graph_node()
|
|
132
|
+
assert en["properties"]["text"] == "" and "text_from" not in en["properties"]
|
|
133
|
+
assert len(en["sources"]) == 1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_collection_identity_wire_shape_and_edges():
|
|
137
|
+
# identity: normalized-title — folder-name and hand-typed forms converge
|
|
138
|
+
assert normalize_collection_title("Hardcore_History") == "hardcore history"
|
|
139
|
+
cid = collection_node_id("Hardcore_History")
|
|
140
|
+
assert cid == collection_node_id("hardcore history")
|
|
141
|
+
assert cid != collection_node_id("Supernova in the East")
|
|
142
|
+
assert cid != SID, "kinds never collide"
|
|
143
|
+
|
|
144
|
+
# wire shape: proposed tool default vs confirmed human act (ae3464fc actor criterion)
|
|
145
|
+
proposed = CollectionNode(title="Hardcore_History", actor="cli:transcribe",
|
|
146
|
+
asserted_at=123.0)
|
|
147
|
+
pn = proposed.to_graph_node()
|
|
148
|
+
assert pn["id"] == cid and pn["label"] == "Collection"
|
|
149
|
+
assert pn["properties"]["status"] == "proposed"
|
|
150
|
+
assert pn["properties"]["root_kind"] == "asserted"
|
|
151
|
+
assert pn["properties"]["actor"] == "cli:transcribe"
|
|
152
|
+
assert pn["properties"]["method"] == "collection-declaration"
|
|
153
|
+
assert pn["properties"]["asserted_at"] == 123.0
|
|
154
|
+
assert pn["properties"]["title"] == "Hardcore_History", "display keeps the raw title"
|
|
155
|
+
assert pn["sources"] == [], "a collection asserts grouping; it ingests nothing"
|
|
156
|
+
confirmed = CollectionNode(title="Hardcore History", status="confirmed").to_graph_node()
|
|
157
|
+
assert confirmed["id"] == cid, "confirming/renaming to an equivalent title is the same node"
|
|
158
|
+
assert confirmed["properties"]["status"] == "confirmed"
|
|
159
|
+
|
|
160
|
+
# membership edges: unordered = PART_OF only; ordered = full fractal spine
|
|
161
|
+
m1 = source_node_id("sha256:ch1")
|
|
162
|
+
m2 = source_node_id("sha256:ch2")
|
|
163
|
+
m3 = source_node_id("sha256:ch3")
|
|
164
|
+
loose = collection_edges(cid, [m1, m3])
|
|
165
|
+
assert [e["relation_type"] for e in loose] == ["PART_OF", "PART_OF"]
|
|
166
|
+
assert all(e["target_id"] == cid for e in loose)
|
|
167
|
+
ordered = collection_edges(cid, [m1, m2, m3], ordered=True)
|
|
168
|
+
rels = sorted(e["relation_type"] for e in ordered)
|
|
169
|
+
assert rels == ["NEXT", "NEXT", "PART_OF", "PART_OF", "PART_OF", "STARTS_WITH"]
|
|
170
|
+
starts = [e for e in ordered if e["relation_type"] == "STARTS_WITH"][0]
|
|
171
|
+
assert starts["source_id"] == cid and starts["target_id"] == m1
|
|
172
|
+
nexts = {(e["source_id"], e["target_id"]) for e in ordered if e["relation_type"] == "NEXT"}
|
|
173
|
+
assert nexts == {(m1, m2), (m2, m3)}
|
|
174
|
+
# late-added member attaches to the SAME node without touching the chain
|
|
175
|
+
late = collection_edges(cid, [source_node_id("sha256:ch2.5")])
|
|
176
|
+
assert late[0]["relation_type"] == "PART_OF" and late[0]["target_id"] == cid
|