cjm-transcript-decomp-core 0.0.2__tar.gz → 0.0.5__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_decomp_core-0.0.2/cjm_transcript_decomp_core.egg-info → cjm_transcript_decomp_core-0.0.5}/PKG-INFO +13 -7
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/README.md +8 -2
- cjm_transcript_decomp_core-0.0.5/cjm_transcript_decomp_core/__init__.py +1 -0
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/alignment.py +95 -4
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/cli.py +93 -19
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/graph.py +45 -15
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/models.py +18 -3
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/pipeline.py +153 -29
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5/cjm_transcript_decomp_core.egg-info}/PKG-INFO +13 -7
- cjm_transcript_decomp_core-0.0.5/cjm_transcript_decomp_core.egg-info/entry_points.txt +5 -0
- cjm_transcript_decomp_core-0.0.5/cjm_transcript_decomp_core.egg-info/requires.txt +5 -0
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/pyproject.toml +4 -1
- cjm_transcript_decomp_core-0.0.5/tests/test_alignment.py +194 -0
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/tests/test_cli.py +14 -1
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/tests/test_graph.py +17 -0
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/tests/test_models.py +3 -1
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/tests/test_pipeline.py +51 -0
- cjm_transcript_decomp_core-0.0.2/cjm_transcript_decomp_core/__init__.py +0 -1
- cjm_transcript_decomp_core-0.0.2/cjm_transcript_decomp_core.egg-info/entry_points.txt +0 -2
- cjm_transcript_decomp_core-0.0.2/cjm_transcript_decomp_core.egg-info/requires.txt +0 -5
- cjm_transcript_decomp_core-0.0.2/tests/test_alignment.py +0 -45
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/LICENSE +0 -0
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/MANIFEST.in +0 -0
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/SOURCES.txt +0 -0
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/dependency_links.txt +0 -0
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/top_level.txt +0 -0
- {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cjm-transcript-decomp-core
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.5
|
|
4
4
|
Summary: A frontend-agnostic core for the transcript decomposition workflow — composes isolated capability workers (forced alignment, VAD, graph storage) into a headless pipeline that decomposes transcription run manifests into a VAD-aligned context-graph spine with traceable provenance, and a CLI as its first driver.
|
|
5
5
|
Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -10,10 +10,10 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
10
10
|
Requires-Python: >=3.12
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Requires-Dist: cjm_substrate>=0.0.
|
|
14
|
-
Requires-Dist: cjm_context_graph_primitives>=0.0.
|
|
15
|
-
Requires-Dist: cjm_capability_primitives>=0.0.
|
|
16
|
-
Requires-Dist: cjm-context-graph-layer>=0.0.
|
|
13
|
+
Requires-Dist: cjm_substrate>=0.0.57
|
|
14
|
+
Requires-Dist: cjm_context_graph_primitives>=0.0.15
|
|
15
|
+
Requires-Dist: cjm_capability_primitives>=0.0.11
|
|
16
|
+
Requires-Dist: cjm-context-graph-layer>=0.0.11
|
|
17
17
|
Requires-Dist: cjm-transcript-graph-schema>=0.0.7
|
|
18
18
|
Dynamic: license-file
|
|
19
19
|
|
|
@@ -38,6 +38,8 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
|
|
|
38
38
|
- `assign_words_to_chunks` _function_ — Assign each FA word to a VAD chunk by timestamp overlap.
|
|
39
39
|
- `build_segments_from_alignment` _function_ — Build a TextSegment per VAD chunk by grouping words by chunk assignment.
|
|
40
40
|
- `map_fa_words_to_text` _function_ — Map forced-alignment words back to character spans in the original text.
|
|
41
|
+
- `sentence_end_word_indices` _function_ — Map capability-delivered sentence boundaries onto FA words (B.5: the
|
|
42
|
+
- `split_chunks_at_sentence_gaps` _function_ — The sentence-split stage (SENTENCE_SPLIT_POLICY, DEC f1024568): refine the
|
|
41
43
|
- `tier1_alignment_checks` _function_ — Tier-1 deterministic pre-filters for the alignment-review seam (no AI).
|
|
42
44
|
|
|
43
45
|
### `cjm_transcript_decomp_core.cli`
|
|
@@ -45,7 +47,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
|
|
|
45
47
|
- `build_parser` _function_ — Build the CLI parser (subcommands: run).
|
|
46
48
|
- `load_capabilities` _function_ — Discover manifests + load each requested capability (default instance).
|
|
47
49
|
- `main` _function_ — CLI entry point (console script: `cjm-transcript-decomp-core`).
|
|
48
|
-
- `run_command` _function_ — Execute the `run` subcommand: extend
|
|
50
|
+
- `run_command` _function_ — Execute the `run` subcommand: extend transcription-run manifest(s) with the fine spine.
|
|
49
51
|
|
|
50
52
|
### `cjm_transcript_decomp_core.graph`
|
|
51
53
|
|
|
@@ -68,16 +70,20 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
|
|
|
68
70
|
|
|
69
71
|
### `cjm_transcript_decomp_core.pipeline`
|
|
70
72
|
|
|
71
|
-
- `build_alignment_composition` _function_ — Build the whole-source M×(VAD ∥ T×FA) composition (D8 fan-in, stage-5 variants).
|
|
73
|
+
- `build_alignment_composition` _function_ — Build the whole-source M×(VAD ∥ T×FA ∥ SEG) composition (D8 fan-in, stage-5 variants).
|
|
72
74
|
- `collect_capability_info` _function_ — Record capability identity + data-DB pointers for the run manifest (provenance).
|
|
75
|
+
- `compute_skeleton_hash` _function_ — Skeleton identity, pure (DEC f1024568 + 9241564f).
|
|
73
76
|
- `confirm_seam` _function_ — HITL approval seam in its cheapest viable form (log + optional CLI prompt).
|
|
77
|
+
- `decomp_replay_handlers` _function_ — The decomp core's replay vocabulary (DEC 426658f1, replay stays DOMAIN-OWNED).
|
|
74
78
|
- `decompose_source` _function_ — Decompose one source into aligned fine segments with per-transcriber variants.
|
|
75
79
|
- `fa_words_from_result` _function_ — Normalize a typed forced-alignment result into FA words (pure; stage 3).
|
|
76
80
|
- `load_source_manifest` _function_ — Load + lightly validate a transcription-core run manifest.
|
|
77
81
|
- `run_decomp` _function_ — Extend every source in a transcription run manifest with its fine spine.
|
|
82
|
+
- `sentence_spans_from_result` _function_ — Normalize a typed sentence-segmentation result into char-span tuples (pure; B.5).
|
|
78
83
|
- `submit_and_wait` _function_ — Submit one capability job, wait for it, and return its result (raise on failure).
|
|
79
84
|
- `vad_chunks_from_result` _function_ — Normalize a typed VAD result into segment-local VAD chunks.
|
|
80
85
|
|
|
81
86
|
## Dependencies
|
|
82
87
|
|
|
83
88
|
**Depends on:** `cjm-capability-primitives`, `cjm-context-graph-layer`, `cjm-context-graph-primitives`, `cjm-substrate`, `cjm-transcript-graph-schema`
|
|
89
|
+
**Used by:** `cjm-transcript-decomp-tui`
|
|
@@ -19,6 +19,8 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
|
|
|
19
19
|
- `assign_words_to_chunks` _function_ — Assign each FA word to a VAD chunk by timestamp overlap.
|
|
20
20
|
- `build_segments_from_alignment` _function_ — Build a TextSegment per VAD chunk by grouping words by chunk assignment.
|
|
21
21
|
- `map_fa_words_to_text` _function_ — Map forced-alignment words back to character spans in the original text.
|
|
22
|
+
- `sentence_end_word_indices` _function_ — Map capability-delivered sentence boundaries onto FA words (B.5: the
|
|
23
|
+
- `split_chunks_at_sentence_gaps` _function_ — The sentence-split stage (SENTENCE_SPLIT_POLICY, DEC f1024568): refine the
|
|
22
24
|
- `tier1_alignment_checks` _function_ — Tier-1 deterministic pre-filters for the alignment-review seam (no AI).
|
|
23
25
|
|
|
24
26
|
### `cjm_transcript_decomp_core.cli`
|
|
@@ -26,7 +28,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
|
|
|
26
28
|
- `build_parser` _function_ — Build the CLI parser (subcommands: run).
|
|
27
29
|
- `load_capabilities` _function_ — Discover manifests + load each requested capability (default instance).
|
|
28
30
|
- `main` _function_ — CLI entry point (console script: `cjm-transcript-decomp-core`).
|
|
29
|
-
- `run_command` _function_ — Execute the `run` subcommand: extend
|
|
31
|
+
- `run_command` _function_ — Execute the `run` subcommand: extend transcription-run manifest(s) with the fine spine.
|
|
30
32
|
|
|
31
33
|
### `cjm_transcript_decomp_core.graph`
|
|
32
34
|
|
|
@@ -49,16 +51,20 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
|
|
|
49
51
|
|
|
50
52
|
### `cjm_transcript_decomp_core.pipeline`
|
|
51
53
|
|
|
52
|
-
- `build_alignment_composition` _function_ — Build the whole-source M×(VAD ∥ T×FA) composition (D8 fan-in, stage-5 variants).
|
|
54
|
+
- `build_alignment_composition` _function_ — Build the whole-source M×(VAD ∥ T×FA ∥ SEG) composition (D8 fan-in, stage-5 variants).
|
|
53
55
|
- `collect_capability_info` _function_ — Record capability identity + data-DB pointers for the run manifest (provenance).
|
|
56
|
+
- `compute_skeleton_hash` _function_ — Skeleton identity, pure (DEC f1024568 + 9241564f).
|
|
54
57
|
- `confirm_seam` _function_ — HITL approval seam in its cheapest viable form (log + optional CLI prompt).
|
|
58
|
+
- `decomp_replay_handlers` _function_ — The decomp core's replay vocabulary (DEC 426658f1, replay stays DOMAIN-OWNED).
|
|
55
59
|
- `decompose_source` _function_ — Decompose one source into aligned fine segments with per-transcriber variants.
|
|
56
60
|
- `fa_words_from_result` _function_ — Normalize a typed forced-alignment result into FA words (pure; stage 3).
|
|
57
61
|
- `load_source_manifest` _function_ — Load + lightly validate a transcription-core run manifest.
|
|
58
62
|
- `run_decomp` _function_ — Extend every source in a transcription run manifest with its fine spine.
|
|
63
|
+
- `sentence_spans_from_result` _function_ — Normalize a typed sentence-segmentation result into char-span tuples (pure; B.5).
|
|
59
64
|
- `submit_and_wait` _function_ — Submit one capability job, wait for it, and return its result (raise on failure).
|
|
60
65
|
- `vad_chunks_from_result` _function_ — Normalize a typed VAD result into segment-local VAD chunks.
|
|
61
66
|
|
|
62
67
|
## Dependencies
|
|
63
68
|
|
|
64
69
|
**Depends on:** `cjm-capability-primitives`, `cjm-context-graph-layer`, `cjm-context-graph-primitives`, `cjm-substrate`, `cjm-transcript-graph-schema`
|
|
70
|
+
**Used by:** `cjm-transcript-decomp-tui`
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.5"
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
"""Pure forced-alignment logic (no capability calls): map FA words back to character spans in the original text, assign words to VAD chunks by timestamp, and build one text segment per VAD chunk. Extracted from the page-centric ForcedAlignmentService (Tier-1 logic)."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
-
from typing import Dict, List, Optional, Tuple
|
|
4
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
5
5
|
|
|
6
6
|
from cjm_transcript_decomp_core.models import FAWord, TextSegment, VADChunk
|
|
7
7
|
|
|
8
8
|
# Strip punctuation for comparison (matches what FA models strip).
|
|
9
9
|
_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE)
|
|
10
10
|
|
|
11
|
+
# Sentence-split policy tag (DEC f1024568): versioned because the policy is a
|
|
12
|
+
# SKELETON IDENTITY input — any change to the split rule must bump it, or
|
|
13
|
+
# re-runs would silently collide with spines a different rule produced.
|
|
14
|
+
# 'capability' (B.5, DEC cc904eee): sentence boundaries now come from a
|
|
15
|
+
# sentence-segmentation CAPABILITY (pySBD first) as char spans over the
|
|
16
|
+
# authoritative text — the v1-v3 token heuristic (closer/abbreviation/stub
|
|
17
|
+
# lists) is RETIRED. The segmenter's identity (capability name + config hash)
|
|
18
|
+
# joins the skeleton-identity composite beside this tag.
|
|
19
|
+
SENTENCE_SPLIT_POLICY = "sentence-split/capability"
|
|
20
|
+
|
|
11
21
|
|
|
12
22
|
def _strip_punct(
|
|
13
23
|
text: str, # Text to normalize
|
|
@@ -89,8 +99,13 @@ def assign_words_to_chunks(
|
|
|
89
99
|
) -> List[int]: # Chunk index for each FA word
|
|
90
100
|
"""Assign each FA word to a VAD chunk by timestamp overlap.
|
|
91
101
|
|
|
92
|
-
Words whose start_time falls within a chunk's [start, end
|
|
93
|
-
that chunk
|
|
102
|
+
Words whose start_time falls within a chunk's [start, end) are assigned to
|
|
103
|
+
that chunk — HALF-OPEN, so a word starting exactly on a shared boundary
|
|
104
|
+
belongs to the chunk that STARTS there (sentence-split cuts land exactly on
|
|
105
|
+
the next word's start when FA words are contiguous; the old inclusive end
|
|
106
|
+
pulled that word one chunk LEFT — the off-by-one the 2026-07-22 probe drive
|
|
107
|
+
caught). Words in silence gaps (incl. exactly at the last chunk's end) go
|
|
108
|
+
to the nearest chunk by time proximity.
|
|
94
109
|
"""
|
|
95
110
|
if not vad_chunks:
|
|
96
111
|
return [0] * len(fa_items)
|
|
@@ -101,7 +116,7 @@ def assign_words_to_chunks(
|
|
|
101
116
|
best_idx = 0
|
|
102
117
|
best_dist = float("inf")
|
|
103
118
|
for i, chunk in enumerate(vad_chunks):
|
|
104
|
-
if chunk.start_time <= t
|
|
119
|
+
if chunk.start_time <= t < chunk.end_time:
|
|
105
120
|
best_idx = i
|
|
106
121
|
break
|
|
107
122
|
dist = min(abs(t - chunk.start_time), abs(t - chunk.end_time))
|
|
@@ -164,3 +179,79 @@ def tier1_alignment_checks(
|
|
|
164
179
|
f"{empty}/{len(segments)} segment(s) have EMPTY text (VAD chunk with no aligned words)"
|
|
165
180
|
)
|
|
166
181
|
return warnings
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def sentence_end_word_indices(
|
|
185
|
+
spans: List[Tuple[int, int]], # FA-word char spans from map_fa_words_to_text (ordered)
|
|
186
|
+
sentence_spans: List[Tuple[int, int]], # Sentence char spans over the SAME text (ordered, non-overlapping)
|
|
187
|
+
) -> Set[int]: # Indices of FA words that END a sentence
|
|
188
|
+
"""Map capability-delivered sentence boundaries onto FA words (B.5: the
|
|
189
|
+
successor of the v1-v3 `_ends_sentence` token heuristic).
|
|
190
|
+
|
|
191
|
+
A word ends a sentence when it is the LAST word whose span STARTS before
|
|
192
|
+
that sentence's end_char. Both lists are ordered over the same text, so a
|
|
193
|
+
single forward merge walk suffices; a sentence no word starts in
|
|
194
|
+
contributes nothing, and the trailing word of the text marks its sentence
|
|
195
|
+
but can never produce a cut (cuts live BETWEEN consecutive words)."""
|
|
196
|
+
out: Set[int] = set()
|
|
197
|
+
wi = 0
|
|
198
|
+
for ss, se in sentence_spans:
|
|
199
|
+
last = None
|
|
200
|
+
while wi < len(spans) and spans[wi][0] < se:
|
|
201
|
+
if spans[wi][0] >= ss: # the word must START inside the sentence
|
|
202
|
+
last = wi
|
|
203
|
+
wi += 1
|
|
204
|
+
if last is not None:
|
|
205
|
+
out.add(last)
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def split_chunks_at_sentence_gaps(
|
|
210
|
+
vad_chunks: List[VADChunk], # The VAD skeleton (segment-local times)
|
|
211
|
+
fa_items: List[FAWord], # The AUTHORITATIVE transcriber's FA words (segment-local times)
|
|
212
|
+
end_words: Set[int], # FA-word indices that END a sentence (sentence_end_word_indices)
|
|
213
|
+
min_chunk_s: float = 0.5, # Min sub-chunk duration — a split never mints a sliver
|
|
214
|
+
) -> List[VADChunk]: # The refined skeleton, re-indexed (identical content when nothing splits)
|
|
215
|
+
"""The sentence-split stage (SENTENCE_SPLIT_POLICY, DEC f1024568): refine the
|
|
216
|
+
VAD skeleton by cutting any chunk whose assigned text crosses a sentence end.
|
|
217
|
+
|
|
218
|
+
Runs POST-FA, PRE-fold: a chunk holding a sentence-ending word that is not
|
|
219
|
+
its last word splits at the corresponding FA word gap (midpoint between the
|
|
220
|
+
ending word's end and the next word's start — the pause the VAD's min-sil
|
|
221
|
+
threshold failed to cut, finding bc69e3e6). Sentence ends arrive
|
|
222
|
+
PRECOMPUTED (B.5): the segmentation capability's char spans over the
|
|
223
|
+
authoritative text, mapped onto FA words by `sentence_end_word_indices` —
|
|
224
|
+
this function no longer inspects text. The split decision reads ONLY the
|
|
225
|
+
authoritative transcriber (montage/textless chunks have no words here and
|
|
226
|
+
pass through untouched); every transcriber then re-folds over the refined
|
|
227
|
+
skeleton, so variants stay per-chunk consistent by construction. Both
|
|
228
|
+
sides of an accepted cut must be >= `min_chunk_s` at accept time (greedy
|
|
229
|
+
left-to-right), so FA jitter cannot mint unplayable slivers.
|
|
230
|
+
"""
|
|
231
|
+
assignments = assign_words_to_chunks(fa_items, vad_chunks)
|
|
232
|
+
by_chunk: Dict[int, List[int]] = {}
|
|
233
|
+
for wi, ci in enumerate(assignments):
|
|
234
|
+
by_chunk.setdefault(ci, []).append(wi)
|
|
235
|
+
|
|
236
|
+
refined: List[VADChunk] = []
|
|
237
|
+
for chunk in vad_chunks:
|
|
238
|
+
words = by_chunk.get(chunk.index, [])
|
|
239
|
+
cuts: List[float] = []
|
|
240
|
+
cur_start = chunk.start_time
|
|
241
|
+
for p in range(len(words) - 1):
|
|
242
|
+
wi, wj = words[p], words[p + 1]
|
|
243
|
+
if wi not in end_words:
|
|
244
|
+
continue
|
|
245
|
+
cut = (fa_items[wi].end_time + fa_items[wj].start_time) / 2.0
|
|
246
|
+
if not (chunk.start_time < cut < chunk.end_time):
|
|
247
|
+
continue
|
|
248
|
+
if cut - cur_start < min_chunk_s or chunk.end_time - cut < min_chunk_s:
|
|
249
|
+
continue
|
|
250
|
+
cuts.append(cut)
|
|
251
|
+
cur_start = cut
|
|
252
|
+
bounds = [chunk.start_time] + cuts + [chunk.end_time]
|
|
253
|
+
for k in range(len(bounds) - 1):
|
|
254
|
+
refined.append(VADChunk(index=0, start_time=bounds[k], end_time=bounds[k + 1]))
|
|
255
|
+
for i, c in enumerate(refined):
|
|
256
|
+
c.index = i
|
|
257
|
+
return refined
|
|
@@ -10,9 +10,11 @@ Prerequisite runtime (once, from the repo root):
|
|
|
10
10
|
cjm-ctl --cjm-config cjm.yaml setup-runtime
|
|
11
11
|
cjm-ctl --cjm-config cjm.yaml install-all --capabilities capabilities_test.yaml --force
|
|
12
12
|
|
|
13
|
-
Then decompose
|
|
13
|
+
Then decompose transcription-core run manifest(s) — a multi-manifest batch
|
|
14
|
+
shares ONE loaded capability stack (models load once, per-manifest runs):
|
|
14
15
|
|
|
15
16
|
cjm-transcript-decomp-core run path/to/transcription-run.json --yes
|
|
17
|
+
cjm-transcript-decomp-core run runs/run_a.json runs/run_b.json --yes
|
|
16
18
|
# GPU runs: opt into CR-7 GPU subtree attribution
|
|
17
19
|
cjm-transcript-decomp-core run run.json --yes --sysmon-capability cjm-capability-monitor-nvidia
|
|
18
20
|
"""
|
|
@@ -21,11 +23,13 @@ import argparse
|
|
|
21
23
|
import asyncio
|
|
22
24
|
import getpass
|
|
23
25
|
import logging
|
|
26
|
+
import os
|
|
24
27
|
from pathlib import Path
|
|
25
28
|
from typing import Any, Dict, List, Optional
|
|
26
29
|
|
|
27
30
|
from cjm_substrate.core.manager import CapabilityManager
|
|
28
31
|
from cjm_substrate.core.queue import JobQueue
|
|
32
|
+
from cjm_substrate.core.workspace import resolve_workspace
|
|
29
33
|
from cjm_transcript_decomp_core.models import DecompConfig
|
|
30
34
|
from cjm_transcript_decomp_core.pipeline import load_source_manifest, run_decomp
|
|
31
35
|
|
|
@@ -41,7 +45,8 @@ def build_parser() -> argparse.ArgumentParser: # Configured CLI parser
|
|
|
41
45
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
42
46
|
|
|
43
47
|
run = sub.add_parser("run", help="Extend a transcription-emitted graph root with the fine spine")
|
|
44
|
-
run.add_argument("
|
|
48
|
+
run.add_argument("manifests", nargs="+",
|
|
49
|
+
help="Transcription-core run manifest JSON(s) — a batch shares ONE loaded capability stack")
|
|
45
50
|
run.add_argument("--manifests-dir", default=".cjm/manifests", help="Capability manifests directory")
|
|
46
51
|
run.add_argument("--vad-capability", default="cjm-capability-silero-vad", help="VAD capability name")
|
|
47
52
|
run.add_argument("--fa-capability", default="cjm-capability-qwen3-forced-aligner", help="Forced-alignment capability name")
|
|
@@ -54,9 +59,32 @@ def build_parser() -> argparse.ArgumentParser: # Configured CLI parser
|
|
|
54
59
|
"default: the manifest's sole transcriber)")
|
|
55
60
|
run.add_argument("--sysmon-capability", default=None, help="monitor capability for GPU subtree attribution (CR-7); loaded first; default: no monitor")
|
|
56
61
|
run.add_argument("--language", default="English", help="Forced-alignment language")
|
|
62
|
+
run.add_argument("--sentence-split", action=argparse.BooleanOptionalAction, default=True,
|
|
63
|
+
help="Run the post-FA sentence-split stage (DEC f1024568): chunks whose "
|
|
64
|
+
"text crosses a sentence end split at the FA word gap; commits a "
|
|
65
|
+
"PARALLEL spine (new skeleton hash) — existing spines are untouched. "
|
|
66
|
+
"DEFAULT-ON (DEC 552bde8d); --no-sentence-split opts out")
|
|
67
|
+
run.add_argument("--seg-capability", default="cjm-capability-pysbd",
|
|
68
|
+
help="Sentence-segmentation capability name (B.5; skipped with --no-sentence-split)")
|
|
69
|
+
run.add_argument("--split-min-chunk-s", type=float, default=0.5,
|
|
70
|
+
help="Sentence-split min sub-chunk duration guard, seconds (identity input)")
|
|
71
|
+
run.add_argument("--respine", action="store_true",
|
|
72
|
+
help="Mint a FRESH skeleton spine under the SAME config (DEC 9241564f): "
|
|
73
|
+
"the run id joins the Segment identity input, so the new spine "
|
|
74
|
+
"coexists with — never collides into — the config-identical one. "
|
|
75
|
+
"The recovery for post-upgrade re-runs the verify-collide refuses "
|
|
76
|
+
"(finding e8458f6e); provenance = this run's manifest")
|
|
57
77
|
run.add_argument("--force", action="store_true", help="Bypass capability-side caches (VAD + FA)")
|
|
58
78
|
run.add_argument("-y", "--yes", action="store_true", help="Auto-accept HITL seams (headless mode)")
|
|
59
|
-
run.add_argument("--output", default=None, help="Decomp-manifest output path (default:
|
|
79
|
+
run.add_argument("--output", default=None, help="Decomp-manifest output path (single-manifest runs only; default: <output-dir>/<run_id>.json)")
|
|
80
|
+
run.add_argument("--output-dir", default=None,
|
|
81
|
+
help="Decomp-manifest output directory (callers with a shared runs dir "
|
|
82
|
+
"pass it so manifests land beside their sources; default: the "
|
|
83
|
+
"workspace's runs/ when one is active, else runs/ under the cwd)")
|
|
84
|
+
run.add_argument("--workspace", default=None,
|
|
85
|
+
help="Workspace root (5daadfc4; default: CJM_WORKSPACE env, else upward walk "
|
|
86
|
+
"from cwd). Supplies the --output-dir default and is exported so "
|
|
87
|
+
"substrate config + capability workers resolve workspace-scoped paths")
|
|
60
88
|
run.add_argument("--actor", default=None,
|
|
61
89
|
help="Journal attribution for who/what initiated this run (default: cli:<username>)")
|
|
62
90
|
run.add_argument("-v", "--verbose", action="store_true", help="DEBUG-level logging")
|
|
@@ -85,11 +113,31 @@ def load_capabilities(
|
|
|
85
113
|
|
|
86
114
|
async def run_command(
|
|
87
115
|
args: argparse.Namespace, # Parsed CLI arguments for the `run` subcommand
|
|
88
|
-
) -> int: # Process exit code (0 =
|
|
89
|
-
"""Execute the `run` subcommand: extend
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
116
|
+
) -> int: # Process exit code (0 = every manifest fully extended + verified)
|
|
117
|
+
"""Execute the `run` subcommand: extend transcription-run manifest(s) with the fine spine.
|
|
118
|
+
|
|
119
|
+
Batch shape (decomp-TUI demand, work item 0ff6bf0f): N manifests ride ONE
|
|
120
|
+
capability stack — the models load once and every manifest reuses them,
|
|
121
|
+
where N CLI invocations would pay N model loads. Each manifest keeps its
|
|
122
|
+
own run_id, decomp manifest, and RUN_STARTED/RUN_FINISHED journal bracket,
|
|
123
|
+
so a batch member is indistinguishable from a solo run downstream. A failed
|
|
124
|
+
member records and the batch continues (unattended queueing must not sink
|
|
125
|
+
the rest); the exit code aggregates across members.
|
|
126
|
+
"""
|
|
127
|
+
# 5daadfc4 workspace: resolve BEFORE any substrate config loads; export so
|
|
128
|
+
# the process tree (substrate config, capability workers) is workspace-scoped.
|
|
129
|
+
ws = resolve_workspace(explicit=getattr(args, "workspace", None))
|
|
130
|
+
if ws is not None:
|
|
131
|
+
os.environ["CJM_WORKSPACE"] = str(ws.root)
|
|
132
|
+
output_dir = (Path(args.output_dir) if args.output_dir
|
|
133
|
+
else (ws.runs_dir if ws is not None else Path("runs")))
|
|
134
|
+
manifest_paths = [str(Path(m).resolve()) for m in args.manifests]
|
|
135
|
+
missing = [m for m in manifest_paths if not Path(m).exists()]
|
|
136
|
+
if missing:
|
|
137
|
+
raise SystemExit(f"source manifest(s) not found: {', '.join(missing)}")
|
|
138
|
+
if args.output and len(manifest_paths) > 1:
|
|
139
|
+
raise SystemExit("--output names ONE decomp manifest — omit it for batch runs "
|
|
140
|
+
"(each lands at runs/<run_id>.json)")
|
|
93
141
|
|
|
94
142
|
cfg = DecompConfig(
|
|
95
143
|
vad_capability=args.vad_capability,
|
|
@@ -99,6 +147,10 @@ async def run_command(
|
|
|
99
147
|
language=args.language,
|
|
100
148
|
force=args.force,
|
|
101
149
|
assume_yes=args.yes,
|
|
150
|
+
sentence_split=args.sentence_split,
|
|
151
|
+
split_min_chunk_s=args.split_min_chunk_s,
|
|
152
|
+
seg_capability=args.seg_capability,
|
|
153
|
+
respine=args.respine,
|
|
102
154
|
)
|
|
103
155
|
|
|
104
156
|
# CR-7 GPU subtree attribution is opt-in: --sysmon-capability threads the monitor
|
|
@@ -109,20 +161,50 @@ async def run_command(
|
|
|
109
161
|
sysmon_capability_name=args.sysmon_capability,
|
|
110
162
|
)
|
|
111
163
|
instance_ids = [cfg.vad_capability, cfg.fa_capability, cfg.graph_capability]
|
|
164
|
+
if cfg.sentence_split:
|
|
165
|
+
# B.5: the segmentation capability loads only when the split stage runs.
|
|
166
|
+
instance_ids.insert(2, cfg.seg_capability)
|
|
112
167
|
load_order = ([args.sysmon_capability] if args.sysmon_capability else []) + instance_ids
|
|
113
168
|
# F10: --graph-db-path threads a caller-wins config into the graph load
|
|
114
169
|
# (the C8 pattern correction-core already used; scratch-graph loop-backs).
|
|
115
170
|
configs = ({cfg.graph_capability: {"db_path": args.graph_db_path}}
|
|
116
171
|
if args.graph_db_path else None)
|
|
117
|
-
|
|
172
|
+
try:
|
|
173
|
+
load_capabilities(manager, load_order, configs=configs)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
# Sentence-split is DEFAULT-ON (DEC 552bde8d): an env without the
|
|
176
|
+
# segmentation capability must refuse with both outs, not crash.
|
|
177
|
+
if cfg.sentence_split and cfg.seg_capability in str(e):
|
|
178
|
+
raise SystemExit(
|
|
179
|
+
f"sentence-split is default-on and needs {cfg.seg_capability}: {e}\n"
|
|
180
|
+
f"either install it (cjm-ctl install-all with the capabilities yaml "
|
|
181
|
+
f"adds the env + manifest additively) or pass --no-sentence-split")
|
|
182
|
+
raise
|
|
118
183
|
|
|
119
184
|
queue = JobQueue(deps=manager, sysmon_capability_name=args.sysmon_capability)
|
|
120
185
|
await queue.start()
|
|
186
|
+
all_ok = True
|
|
121
187
|
try:
|
|
122
188
|
# CR-14 follow-up: actor attribution (operator identity by default;
|
|
123
189
|
# agents/services pass --actor explicitly).
|
|
124
190
|
actor = args.actor or f"cli:{getpass.getuser()}"
|
|
125
|
-
|
|
191
|
+
for mp in manifest_paths:
|
|
192
|
+
try:
|
|
193
|
+
manifest = await run_decomp(manager, queue, cfg, mp, actor=actor)
|
|
194
|
+
except Exception as e: # One member's failure must not sink the batch
|
|
195
|
+
logger.error(f"decomp failed for {mp}: {e}")
|
|
196
|
+
print(f"FAILED {mp}: {e}")
|
|
197
|
+
all_ok = False
|
|
198
|
+
continue
|
|
199
|
+
out = (Path(args.output) if args.output
|
|
200
|
+
else output_dir / f"{manifest.run_id}.json")
|
|
201
|
+
manifest.save(out, workspace=ws)
|
|
202
|
+
n_manifest_sources = len(load_source_manifest(mp).get("sources", []) or [])
|
|
203
|
+
n_sources = len(manifest.sources)
|
|
204
|
+
n_segs = sum(s.segment_count for s in manifest.sources)
|
|
205
|
+
print(f"decomp manifest: {out}")
|
|
206
|
+
print(f"sources extended: {n_sources}/{n_manifest_sources} segment nodes: {n_segs}")
|
|
207
|
+
all_ok = all_ok and (n_sources == n_manifest_sources)
|
|
126
208
|
finally:
|
|
127
209
|
await queue.stop()
|
|
128
210
|
for iid in reversed(load_order): # Reverse load order; the monitor unloads last
|
|
@@ -130,15 +212,7 @@ async def run_command(
|
|
|
130
212
|
manager.unload_capability(iid)
|
|
131
213
|
except Exception as e: # Best-effort teardown; never mask the run's outcome
|
|
132
214
|
logger.warning(f"unload {iid} failed: {e}")
|
|
133
|
-
|
|
134
|
-
out = Path(args.output) if args.output else Path("runs") / f"{manifest.run_id}.json"
|
|
135
|
-
manifest.save(out)
|
|
136
|
-
n_manifest_sources = len(load_source_manifest(manifest_path).get("sources", []) or [])
|
|
137
|
-
n_sources = len(manifest.sources)
|
|
138
|
-
n_segs = sum(s.segment_count for s in manifest.sources)
|
|
139
|
-
print(f"decomp manifest: {out}")
|
|
140
|
-
print(f"sources extended: {n_sources}/{n_manifest_sources} segment nodes: {n_segs}")
|
|
141
|
-
return 0 if n_sources == n_manifest_sources else 1
|
|
215
|
+
return 0 if all_ok else 1
|
|
142
216
|
|
|
143
217
|
|
|
144
218
|
def main(
|
|
@@ -66,9 +66,10 @@ def resolve_root_ids(
|
|
|
66
66
|
def build_extension_payload(
|
|
67
67
|
source_entry: Dict[str, Any], # One source entry from the transcription manifest
|
|
68
68
|
capabilities_info: Dict[str, Dict[str, Any]], # The transcription manifest's capabilities block
|
|
69
|
-
|
|
69
|
+
skeleton_config_hash: str, # THIS run's skeleton-config hash (Segment identity input; = the VAD config hash when no split stage ran)
|
|
70
70
|
text_from: str, # Authoritative transcriber (layer-0 text designation)
|
|
71
71
|
segments: List[DecompSegment], # Ordered aligned segments (per-transcriber variants attached)
|
|
72
|
+
split_policy: Optional[str] = None, # Split policy+version that refined the skeleton (node metadata, never identity)
|
|
72
73
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: # (nodes, edges, ids)
|
|
73
74
|
"""Build the fine-spine EXTENSION payload (pure; no capability calls).
|
|
74
75
|
|
|
@@ -100,12 +101,12 @@ def build_extension_payload(
|
|
|
100
101
|
end_char=var.end_char, text=var.text))
|
|
101
102
|
used_transcripts.add(tid)
|
|
102
103
|
node = SegmentNode(
|
|
103
|
-
rendition=a["rendition"], vad_config_hash=
|
|
104
|
+
rendition=a["rendition"], vad_config_hash=skeleton_config_hash,
|
|
104
105
|
chunk_start=seg.chunk_start, chunk_end=seg.chunk_end,
|
|
105
106
|
index=seg.index, start_time=seg.start_time, end_time=seg.end_time,
|
|
106
107
|
text=seg.text, audio_hash=a["model_input_hash"], source=source_id,
|
|
107
108
|
text_from=(a["transcripts"].get(text_from) if seg.text.strip() else None),
|
|
108
|
-
text_slices=slices,
|
|
109
|
+
split_policy=split_policy, text_slices=slices,
|
|
109
110
|
)
|
|
110
111
|
nodes.append(node.to_graph_node())
|
|
111
112
|
seg_ids.append(node.id)
|
|
@@ -153,6 +154,7 @@ async def verify_source(
|
|
|
153
154
|
graph_id: str, # Graph-storage capability id
|
|
154
155
|
source_id: str, # Source node id to verify
|
|
155
156
|
rendition_ids: List[str], # The AudioRendition ids the run extended (the fine spine is scoped to these)
|
|
157
|
+
segment_ids: Optional[List[str]] = None, # THIS run's committed Segment ids — id-scopes the fine-layer checks (parallel spines); None = all segments under the renditions
|
|
156
158
|
) -> Optional[SourceVerification]: # Result, or None if the Source is not found
|
|
157
159
|
"""Verify a Source's committed extension via server-side AGGREGATES (D13/D19).
|
|
158
160
|
|
|
@@ -204,21 +206,49 @@ async def verify_source(
|
|
|
204
206
|
res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
|
|
205
207
|
return int(res.count or 0)
|
|
206
208
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
209
|
+
proj_rows: List[Dict[str, Any]] = []
|
|
210
|
+
if segment_ids is None:
|
|
211
|
+
n_segs = await _ncount()
|
|
212
|
+
seg_next = await _ecount(relation_type=SpineRelations.NEXT, source_related=part_of_rend)
|
|
213
|
+
seg_part_of = await _ecount(relation_type=SpineRelations.PART_OF, target_ids=rendition_ids)
|
|
214
|
+
rend_starts = await _ecount(relation_type=SpineRelations.STARTS_WITH, source_ids=rendition_ids)
|
|
215
|
+
missing_start = await _ncount(where=[PropertyPredicate("start_time", "is_null")])
|
|
216
|
+
missing_end = await _ncount(where=[PropertyPredicate("end_time", "is_null")])
|
|
217
|
+
# Bounded projection: sources + owning rendition in ONE pass.
|
|
218
|
+
sq = NodeQuery(label=TranscriptGraphLabels.SEGMENT, related=part_of_rend,
|
|
219
|
+
project=["sources", "rendition_id"])
|
|
220
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=sq.to_dict())
|
|
221
|
+
proj_rows = list(res.rows or [])
|
|
222
|
+
else:
|
|
223
|
+
# Id-scoped mode (parallel spines, DEC f1024568): every fine-layer
|
|
224
|
+
# aggregate counts ONLY this run's committed segments — a coexisting
|
|
225
|
+
# sibling skeleton under the same renditions (its own NEXT chain +
|
|
226
|
+
# STARTS_WITH anchor) must not fail the structural checks. Batched
|
|
227
|
+
# (bounded reads; the 500-id convention).
|
|
228
|
+
async def _icount(ids, **kw):
|
|
229
|
+
q = NodeQuery(ids=list(ids), label=TranscriptGraphLabels.SEGMENT,
|
|
230
|
+
count=True, **kw)
|
|
231
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
|
|
232
|
+
return int(res.count or 0)
|
|
233
|
+
|
|
234
|
+
n_segs = seg_next = seg_part_of = rend_starts = missing_start = missing_end = 0
|
|
235
|
+
for i in range(0, len(segment_ids), 500):
|
|
236
|
+
b = list(segment_ids[i:i + 500])
|
|
237
|
+
n_segs += await _icount(b)
|
|
238
|
+
seg_next += await _ecount(relation_type=SpineRelations.NEXT, source_ids=b)
|
|
239
|
+
seg_part_of += await _ecount(relation_type=SpineRelations.PART_OF, source_ids=b)
|
|
240
|
+
rend_starts += await _ecount(relation_type=SpineRelations.STARTS_WITH, target_ids=b)
|
|
241
|
+
missing_start += await _icount(b, where=[PropertyPredicate("start_time", "is_null")])
|
|
242
|
+
missing_end += await _icount(b, where=[PropertyPredicate("end_time", "is_null")])
|
|
243
|
+
pq = NodeQuery(ids=b, label=TranscriptGraphLabels.SEGMENT,
|
|
244
|
+
project=["sources", "rendition_id"])
|
|
245
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=pq.to_dict())
|
|
246
|
+
proj_rows.extend(res.rows or [])
|
|
247
|
+
|
|
218
248
|
missing_sources = 0
|
|
219
249
|
locators = set()
|
|
220
250
|
owning_renditions = set()
|
|
221
|
-
for r in
|
|
251
|
+
for r in proj_rows:
|
|
222
252
|
sources = r.get("sources") or []
|
|
223
253
|
if not sources:
|
|
224
254
|
missing_sources += 1
|
|
@@ -7,6 +7,8 @@ from dataclasses import asdict, dataclass, field
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any, Dict, List, Optional, Union
|
|
9
9
|
|
|
10
|
+
from cjm_substrate.core.workspace import relativize_recorded
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
@dataclass
|
|
12
14
|
class FAWord:
|
|
@@ -115,6 +117,10 @@ class DecompConfig:
|
|
|
115
117
|
media_type: str = "audio" # Source media type
|
|
116
118
|
force: bool = False # Bypass capability-side caches (VAD + FA)
|
|
117
119
|
assume_yes: bool = False # Auto-accept HITL seams (headless mode)
|
|
120
|
+
sentence_split: bool = True # Run the post-FA sentence-split stage (DEC f1024568; parallel spine) — DEFAULT-ON per DEC 552bde8d (hiding splits just hides model failures)
|
|
121
|
+
split_min_chunk_s: float = 0.5 # Sentence-split min sub-chunk duration guard (seconds)
|
|
122
|
+
seg_capability: str = "cjm-capability-pysbd" # Sentence-segmentation capability id (B.5; loaded only when sentence_split)
|
|
123
|
+
respine: bool = False # Mint a FRESH skeleton under the SAME config (DEC 9241564f): the run id joins the identity input, so the new spine never collides with the config-identical one (post-upgrade re-runs, finding e8458f6e)
|
|
118
124
|
|
|
119
125
|
def to_dict(self) -> Dict[str, Any]: # Plain-dict snapshot for the manifest
|
|
120
126
|
"""Serialize to a plain dict."""
|
|
@@ -151,9 +157,11 @@ class DecompManifest:
|
|
|
151
157
|
source_version: str = "" # Upstream manifest schema version
|
|
152
158
|
capabilities: Dict[str, Dict[str, Any]] = field(default_factory=dict) # instance_id -> identity/db_path/config_hash
|
|
153
159
|
sources: List[DecompSourceRecord] = field(default_factory=list) # Extended sources, input order
|
|
160
|
+
skeleton_config_hash: str = "" # Segment identity input (0.2.2): VAD config hash, or the composite when a split stage ran
|
|
161
|
+
split_policy: Optional[str] = None # Split policy+version that refined this run's skeleton (None = raw VAD)
|
|
154
162
|
|
|
155
163
|
FORMAT: str = field(default="cjm-transcript-decomp-core/run-manifest", repr=False) # Format tag
|
|
156
|
-
VERSION: str = field(default="0.2.
|
|
164
|
+
VERSION: str = field(default="0.2.3", repr=False) # Schema version (0.2.3: capability-driven sentence split — seg capability in `capabilities` + segmenter identity in the skeleton composite)
|
|
157
165
|
|
|
158
166
|
def to_dict(self) -> Dict[str, Any]: # Plain-dict form for JSON serialization
|
|
159
167
|
"""Serialize to a plain dict with nested sources."""
|
|
@@ -167,17 +175,24 @@ class DecompManifest:
|
|
|
167
175
|
"source_format": self.source_format,
|
|
168
176
|
"source_version": self.source_version,
|
|
169
177
|
"capabilities": self.capabilities,
|
|
178
|
+
"skeleton_config_hash": self.skeleton_config_hash,
|
|
179
|
+
"split_policy": self.split_policy,
|
|
170
180
|
"sources": [s.to_dict() for s in self.sources],
|
|
171
181
|
}
|
|
172
182
|
|
|
173
183
|
def save(
|
|
174
184
|
self,
|
|
175
185
|
path: Union[str, Path], # Destination JSON file (parent dirs created)
|
|
186
|
+
workspace=None, # Active Workspace; owned paths record as ${WS}/<rel> (5daadfc4 rung f)
|
|
176
187
|
) -> Path: # The written path
|
|
177
|
-
"""Write the manifest as pretty-printed JSON.
|
|
188
|
+
"""Write the manifest as pretty-printed JSON.
|
|
189
|
+
|
|
190
|
+
With `workspace`, recorded paths under its root take the ${WS}/ token
|
|
191
|
+
form (relativize_recorded), so the manifest relocates with the
|
|
192
|
+
workspace; readers resolve via resolve_recorded_tree at load."""
|
|
178
193
|
out = Path(path)
|
|
179
194
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
-
out.write_text(json.dumps(self.to_dict(), indent=2))
|
|
195
|
+
out.write_text(json.dumps(relativize_recorded(self.to_dict(), workspace), indent=2))
|
|
181
196
|
return out
|
|
182
197
|
|
|
183
198
|
|