cjm-transcript-decomp-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_decomp_core/__init__.py +1 -0
- cjm_transcript_decomp_core/alignment.py +166 -0
- cjm_transcript_decomp_core/cli.py +155 -0
- cjm_transcript_decomp_core/graph.py +245 -0
- cjm_transcript_decomp_core/models.py +186 -0
- cjm_transcript_decomp_core/pipeline.py +475 -0
- cjm_transcript_decomp_core-0.0.1.dist-info/METADATA +125 -0
- cjm_transcript_decomp_core-0.0.1.dist-info/RECORD +12 -0
- cjm_transcript_decomp_core-0.0.1.dist-info/WHEEL +5 -0
- cjm_transcript_decomp_core-0.0.1.dist-info/entry_points.txt +2 -0
- cjm_transcript_decomp_core-0.0.1.dist-info/licenses/LICENSE +201 -0
- cjm_transcript_decomp_core-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Lean data shapes for the transcript-decomposition pipeline: in-core mirrors of the forced-alignment / VAD / text DTOs (no FastHTML deps), run configuration, the committed graph-segment carrier, and the decomposition run manifest (proto-bundle)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class FAWord:
|
|
13
|
+
"""One word-level forced-alignment result (segment-local times).
|
|
14
|
+
|
|
15
|
+
Lean in-core mirror of the forced-alignment capability's `ForcedAlignItem`
|
|
16
|
+
wire shape, so the core needs no interface-library dependency for a
|
|
17
|
+
three-field DTO (pass-2 evidence: the typed FA result belongs to the
|
|
18
|
+
forced-alignment adapter interface).
|
|
19
|
+
"""
|
|
20
|
+
text: str # The aligned word (punctuation typically stripped by the model)
|
|
21
|
+
start_time: float # Word start in seconds (relative to the segment audio)
|
|
22
|
+
end_time: float # Word end in seconds (relative to the segment audio)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_wire(
|
|
26
|
+
cls,
|
|
27
|
+
d: Dict[str, Any], # Capability wire dict
|
|
28
|
+
) -> "FAWord": # Parsed word (extra keys ignored)
|
|
29
|
+
"""Build from a capability wire dict, tolerating extra keys."""
|
|
30
|
+
return cls(
|
|
31
|
+
text=str(d.get("text", "")),
|
|
32
|
+
start_time=float(d.get("start_time", 0.0)),
|
|
33
|
+
end_time=float(d.get("end_time", 0.0)),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class VADChunk:
|
|
39
|
+
"""A voice-activity time range within one pipeline segment (segment-local)."""
|
|
40
|
+
index: int # Chunk index within the pipeline segment
|
|
41
|
+
start_time: float # Start in seconds (relative to the segment audio)
|
|
42
|
+
end_time: float # End in seconds (relative to the segment audio)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def duration(self) -> float: # Chunk duration in seconds
|
|
46
|
+
"""Chunk duration."""
|
|
47
|
+
return self.end_time - self.start_time
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class TextSegment:
|
|
52
|
+
"""A text segment produced by alignment, before graph commit.
|
|
53
|
+
|
|
54
|
+
Lean in-core mirror of the page-centric `TextSegment` (which imports FastHTML
|
|
55
|
+
card-stack types); the core carries only the alignment-relevant fields
|
|
56
|
+
(E11 precedent — keep core logic free of UI-adjacent deps).
|
|
57
|
+
"""
|
|
58
|
+
index: int # Position within its pipeline segment
|
|
59
|
+
text: str # Segment text content
|
|
60
|
+
source_id: Optional[str] = None # Upstream transcription row id (job_id)
|
|
61
|
+
source_provider_id: Optional[str] = None # Producing capability name
|
|
62
|
+
start_char: Optional[int] = None # Start char offset into the pipeline-segment text
|
|
63
|
+
end_char: Optional[int] = None # End char offset into the pipeline-segment text
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class SegmentVariant:
|
|
68
|
+
"""One transcriber's text + char range for one fine segment (stage 5).
|
|
69
|
+
|
|
70
|
+
Becomes a `GraphNodeRef(Transcript) + CharSlice` provenance ref on the
|
|
71
|
+
committed Segment node — text is stored ONCE per transcriber at the coarse
|
|
72
|
+
Transcript node; fine-grained variants are SLICES, never duplicated text
|
|
73
|
+
(Thread-1 slices-until-promoted)."""
|
|
74
|
+
transcriber: str # Transcriber capability name
|
|
75
|
+
text: str # This transcriber's text for the chunk
|
|
76
|
+
start_char: Optional[int] # Char offset into that transcriber's pipeline-segment text
|
|
77
|
+
end_char: Optional[int] # End char offset
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> Dict[str, Any]: # Plain-dict form
|
|
80
|
+
"""Serialize to a plain dict."""
|
|
81
|
+
return asdict(self)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class DecompSegment:
|
|
86
|
+
"""One fine spine segment (stage 5: shared audio-side skeleton + per-transcriber variants).
|
|
87
|
+
|
|
88
|
+
Identity is AUDIO-SIDE (owning pipeline segment + chunk range) — shared
|
|
89
|
+
across transcribers by construction; `text` is the AUTHORITATIVE
|
|
90
|
+
(accuracy-transcriber) alignment, with every transcriber's chunk text
|
|
91
|
+
riding `variants` (the authoritative one included)."""
|
|
92
|
+
index: int # Source-wide 0-based fine-spine position
|
|
93
|
+
text: str # Authoritative text (the --text-from transcriber's alignment; "" = no aligned words)
|
|
94
|
+
start_time: float # Start in source-audio seconds (navigation)
|
|
95
|
+
end_time: float # End in source-audio seconds
|
|
96
|
+
chunk_start: float # VAD chunk start (chunk-local seconds within the pipeline-segment WAV)
|
|
97
|
+
chunk_end: float # VAD chunk end (chunk-local seconds)
|
|
98
|
+
vad_chunk_index: int # Chunk index within its pipeline segment
|
|
99
|
+
pseg_index: int # Owning pipeline-segment (AudioSegment) index within the source
|
|
100
|
+
variants: List[SegmentVariant] = field(default_factory=list) # Per-transcriber chunk texts + char ranges
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> Dict[str, Any]: # Plain-dict form
|
|
103
|
+
"""Serialize to a plain dict."""
|
|
104
|
+
return asdict(self)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class DecompConfig:
|
|
109
|
+
"""Configuration for one transcript-decomposition run."""
|
|
110
|
+
vad_capability: str = "cjm-capability-silero-vad" # VAD capability id
|
|
111
|
+
fa_capability: str = "cjm-capability-qwen3-forced-aligner" # Forced-alignment capability id
|
|
112
|
+
graph_capability: str = "cjm-capability-graph-sqlite" # Graph-storage capability id
|
|
113
|
+
text_from: Optional[str] = None # Authoritative transcriber for layer-0 text (None: sole transcriber; REQUIRED for multi-transcriber manifests)
|
|
114
|
+
language: str = "English" # Forced-alignment language
|
|
115
|
+
media_type: str = "audio" # Source media type
|
|
116
|
+
force: bool = False # Bypass capability-side caches (VAD + FA)
|
|
117
|
+
assume_yes: bool = False # Auto-accept HITL seams (headless mode)
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> Dict[str, Any]: # Plain-dict snapshot for the manifest
|
|
120
|
+
"""Serialize to a plain dict."""
|
|
121
|
+
return asdict(self)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class DecompSourceRecord:
|
|
126
|
+
"""Record of one Source whose fine spine this run committed (stage 5:
|
|
127
|
+
`Document` dissolved into `Source` — decomp EXTENDS the transcription-
|
|
128
|
+
emitted root rather than creating a document)."""
|
|
129
|
+
source_node_id: str # Graph Source node id (deterministic; recomputable from the content hash)
|
|
130
|
+
source_path: str # Originating source audio path (from the upstream manifest)
|
|
131
|
+
title: str # Source display title
|
|
132
|
+
segment_count: int # Number of fine Segment nodes committed
|
|
133
|
+
segment_ids: List[str] = field(default_factory=list) # Graph Segment node ids, in order
|
|
134
|
+
|
|
135
|
+
def to_dict(self) -> Dict[str, Any]: # Plain-dict form
|
|
136
|
+
"""Serialize to a plain dict."""
|
|
137
|
+
return asdict(self)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class DecompManifest:
|
|
142
|
+
"""Durable record of one decomposition run (proto-bundle; see CR-20).
|
|
143
|
+
|
|
144
|
+
Schema 0.2.0 (stage 5): `documents` became `sources` (Document dissolved
|
|
145
|
+
into Source); entries carry the deterministic Source node id."""
|
|
146
|
+
run_id: str # Unique run identifier
|
|
147
|
+
created_at: float # Unix timestamp at run start
|
|
148
|
+
config: Dict[str, Any] # DecompConfig snapshot
|
|
149
|
+
source_manifest: str # Path to the consumed transcription run manifest
|
|
150
|
+
source_format: str = "" # Upstream manifest format tag (interchange contract)
|
|
151
|
+
source_version: str = "" # Upstream manifest schema version
|
|
152
|
+
capabilities: Dict[str, Dict[str, Any]] = field(default_factory=dict) # instance_id -> identity/db_path/config_hash
|
|
153
|
+
sources: List[DecompSourceRecord] = field(default_factory=list) # Extended sources, input order
|
|
154
|
+
|
|
155
|
+
FORMAT: str = field(default="cjm-transcript-decomp-core/run-manifest", repr=False) # Format tag
|
|
156
|
+
VERSION: str = field(default="0.2.1", repr=False) # Schema version
|
|
157
|
+
|
|
158
|
+
def to_dict(self) -> Dict[str, Any]: # Plain-dict form for JSON serialization
|
|
159
|
+
"""Serialize to a plain dict with nested sources."""
|
|
160
|
+
return {
|
|
161
|
+
"format": self.FORMAT,
|
|
162
|
+
"version": self.VERSION,
|
|
163
|
+
"run_id": self.run_id,
|
|
164
|
+
"created_at": self.created_at,
|
|
165
|
+
"config": self.config,
|
|
166
|
+
"source_manifest": self.source_manifest,
|
|
167
|
+
"source_format": self.source_format,
|
|
168
|
+
"source_version": self.source_version,
|
|
169
|
+
"capabilities": self.capabilities,
|
|
170
|
+
"sources": [s.to_dict() for s in self.sources],
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
def save(
|
|
174
|
+
self,
|
|
175
|
+
path: Union[str, Path], # Destination JSON file (parent dirs created)
|
|
176
|
+
) -> Path: # The written path
|
|
177
|
+
"""Write the manifest as pretty-printed JSON."""
|
|
178
|
+
out = Path(path)
|
|
179
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
out.write_text(json.dumps(self.to_dict(), indent=2))
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def new_run_id() -> str: # e.g. "decomp_20260607_153000_1a2b3c4d"
|
|
185
|
+
"""Generate a unique, sortable decomposition run id."""
|
|
186
|
+
return f"decomp_{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""The headless decomposition pipeline (stage 5: decomp is an EXTENDER). Load a transcription run manifest, verify the transcription-emitted graph root exists (the graph begins at transcription), then per source per pipeline-segment run VAD + per-transcriber forced alignment, build one aligned segment per VAD chunk with per-transcriber text variants, and attach the fine spine under the existing AudioSegment nodes via the layer's idempotent extend_graph — with HITL approval seams between alignment, commit, and the next source."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from cjm_capability_primitives.forced_alignment import ForcedAlignResult
|
|
11
|
+
from cjm_capability_primitives.vad import VADResult
|
|
12
|
+
from cjm_context_graph_layer.declare import Derivation, derivation_to_graph
|
|
13
|
+
from cjm_context_graph_layer.ops import extend_graph, graph_task
|
|
14
|
+
from cjm_substrate.core.empirical_store import compute_config_hash
|
|
15
|
+
from cjm_substrate.core.journal_store import JournalEvent, SubstrateEventType
|
|
16
|
+
from cjm_substrate.core.manager import CapabilityManager
|
|
17
|
+
from cjm_substrate.core.ports import Composition, CompositionNode, NodeState
|
|
18
|
+
from cjm_substrate.core.queue import JobQueue, JobStatus
|
|
19
|
+
from cjm_transcript_decomp_core.alignment import (assign_words_to_chunks,
|
|
20
|
+
build_segments_from_alignment,
|
|
21
|
+
map_fa_words_to_text, tier1_alignment_checks)
|
|
22
|
+
from cjm_transcript_decomp_core.graph import (build_extension_payload, resolve_root_ids,
|
|
23
|
+
verify_source)
|
|
24
|
+
from cjm_transcript_decomp_core.models import (DecompConfig, DecompManifest, DecompSegment,
|
|
25
|
+
DecompSourceRecord, FAWord, new_run_id,
|
|
26
|
+
SegmentVariant, VADChunk)
|
|
27
|
+
|
|
28
|
+
# Typed wire-kind registration (stage 2): importing the DTO classes is what
|
|
29
|
+
# lets the proxy's wire_decode hand this host process TYPED results. Both result
|
|
30
|
+
# nouns live in cjm-capability-primitives (Option C / PILLAR 1c — the tool/host
|
|
31
|
+
# depend on the data noun, never the adapter machinery). The tuple keeps these
|
|
32
|
+
# SIDE-EFFECT imports referenced so the canonical emit cannot prune them.
|
|
33
|
+
|
|
34
|
+
_REGISTERED_WIRE_KINDS = (VADResult, ForcedAlignResult)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def submit_and_wait(
|
|
40
|
+
queue: JobQueue, # Started job queue
|
|
41
|
+
instance_id: str, # Capability instance to invoke
|
|
42
|
+
*,
|
|
43
|
+
timeout: Optional[float] = None, # Seconds to wait; None = no limit
|
|
44
|
+
**kwargs, # Forwarded to the capability's execute()
|
|
45
|
+
) -> Any: # The completed job's result payload
|
|
46
|
+
"""Submit one capability job, wait for it, and return its result (raise on failure)."""
|
|
47
|
+
job_id = await queue.submit(instance_id, **kwargs)
|
|
48
|
+
job = await queue.wait_for_job(job_id, timeout=timeout)
|
|
49
|
+
if job.status != JobStatus.completed:
|
|
50
|
+
raise RuntimeError(f"{instance_id} job {job_id} {job.status}: {job.error}")
|
|
51
|
+
return job.result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_source_manifest(
|
|
55
|
+
path: str, # Path to a transcription-core run manifest JSON
|
|
56
|
+
) -> Dict[str, Any]: # Parsed manifest dict
|
|
57
|
+
"""Load + lightly validate a transcription-core run manifest.
|
|
58
|
+
|
|
59
|
+
Consumed as untyped JSON — the format/version tags are the only interchange
|
|
60
|
+
contract (no shared schema type across cores; manifest-as-interchange, CR-20).
|
|
61
|
+
"""
|
|
62
|
+
data = json.loads(Path(path).read_text())
|
|
63
|
+
fmt = data.get("format", "")
|
|
64
|
+
if "transcription-core" not in fmt:
|
|
65
|
+
logger.warning(f"unexpected source manifest format: {fmt!r} (continuing)")
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def vad_chunks_from_result(
|
|
70
|
+
result: VADResult, # Typed VAD result (wire-decoded at the proxy)
|
|
71
|
+
) -> List[VADChunk]: # Segment-local VAD chunks, sorted, re-indexed
|
|
72
|
+
"""Normalize a typed VAD result into segment-local VAD chunks.
|
|
73
|
+
|
|
74
|
+
Pure normalizer (stage 3): the capability invocation moved into the
|
|
75
|
+
per-source composition (`build_alignment_composition`); this folds one
|
|
76
|
+
node's typed result. Per-segment VAD (not whole-source) is the validated
|
|
77
|
+
decomp path — originally a browser Web-Audio constraint, kept on the
|
|
78
|
+
merits (D11).
|
|
79
|
+
"""
|
|
80
|
+
chunks = [VADChunk(index=0, start_time=float(r.start), end_time=float(r.end))
|
|
81
|
+
for r in result.ranges]
|
|
82
|
+
chunks.sort(key=lambda c: c.start_time)
|
|
83
|
+
for i, c in enumerate(chunks):
|
|
84
|
+
c.index = i
|
|
85
|
+
return chunks
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def fa_words_from_result(
|
|
89
|
+
result: "ForcedAlignResult", # Typed FA result (wire-decoded at the proxy)
|
|
90
|
+
) -> List[FAWord]: # Word-level alignment results
|
|
91
|
+
"""Normalize a typed forced-alignment result into FA words (pure; stage 3)."""
|
|
92
|
+
return [FAWord(text=str(it.text), start_time=float(it.start_time),
|
|
93
|
+
end_time=float(it.end_time))
|
|
94
|
+
for it in result.items]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_alignment_composition(
|
|
98
|
+
seg_list: List[Dict[str, Any]], # Pipeline-segment entries from the transcription manifest (0.2.0)
|
|
99
|
+
vad_id: str, # VAD capability instance id
|
|
100
|
+
fa_id: str, # Forced-alignment capability instance id
|
|
101
|
+
transcribers: List[str], # Transcriber names to align (manifest `transcripts` keys, stable order)
|
|
102
|
+
force: bool = False, # Per-call cache-bypass control flag
|
|
103
|
+
) -> Tuple[Composition, List[Dict[str, Any]]]: # (composition, per-pseg meta rows)
|
|
104
|
+
"""Build the whole-source M×(VAD ∥ T×FA) composition (D8 fan-in, stage-5 variants).
|
|
105
|
+
|
|
106
|
+
Each pipeline segment contributes one VAD node + one FA node PER TRANSCRIBER
|
|
107
|
+
with non-empty text — VAD is audio-only (run once; the shared skeleton), FA
|
|
108
|
+
maps each transcriber's text onto it. All nodes are independent; the host's
|
|
109
|
+
alignment fold fans them in. Pipeline segments where EVERY transcriber's
|
|
110
|
+
text is empty are recorded as skipped meta rows and contribute no nodes.
|
|
111
|
+
|
|
112
|
+
Stage 8 (Option C): every node rides the TASK CHANNEL now — VAD on
|
|
113
|
+
vad/detect_speech, FA on forced_alignment/align — and per-call `force` rides
|
|
114
|
+
`control` (the adapters own caching). The native-routed path is gone.
|
|
115
|
+
"""
|
|
116
|
+
nodes: List[CompositionNode] = []
|
|
117
|
+
metas: List[Dict[str, Any]] = []
|
|
118
|
+
for i, pseg in enumerate(seg_list):
|
|
119
|
+
# Manifest entries are JSON dicts (manifest-as-interchange, CR-20).
|
|
120
|
+
model_input = str(pseg.get("model_input_path", ""))
|
|
121
|
+
seg_start = float(pseg.get("start", 0.0))
|
|
122
|
+
transcripts = pseg.get("transcripts") or {}
|
|
123
|
+
texts = {t: str((transcripts.get(t) or {}).get("text") or "")
|
|
124
|
+
for t in transcribers if t in transcripts}
|
|
125
|
+
nonempty = {t: x for t, x in texts.items() if x.strip()}
|
|
126
|
+
if not nonempty:
|
|
127
|
+
metas.append({"skipped": True, "seg_start": seg_start, "pseg_index": i})
|
|
128
|
+
continue
|
|
129
|
+
vad_n = f"vad_{i:04d}"
|
|
130
|
+
# VAD via the task channel (vad/detect_speech); the adapter owns the cache
|
|
131
|
+
# + force. model_input is already model-ready (16k mono, converted upstream
|
|
132
|
+
# by transcription-core), so no convert here.
|
|
133
|
+
nodes.append(CompositionNode(vad_n, vad_id,
|
|
134
|
+
{"audio": model_input},
|
|
135
|
+
task_name="vad", method="detect_speech",
|
|
136
|
+
control={"force": force}))
|
|
137
|
+
fa_nodes: Dict[str, str] = {}
|
|
138
|
+
for ti, t in enumerate(transcribers):
|
|
139
|
+
if t not in nonempty:
|
|
140
|
+
continue
|
|
141
|
+
fa_n = f"fa_t{ti}_{i:04d}"
|
|
142
|
+
# FA via the task channel (forced_alignment/align); the adapter owns
|
|
143
|
+
# the cache + force. text is the per-transcriber transcript aligned
|
|
144
|
+
# against the (model-ready) audio.
|
|
145
|
+
nodes.append(CompositionNode(fa_n, fa_id,
|
|
146
|
+
{"audio": model_input, "text": nonempty[t]},
|
|
147
|
+
task_name="forced_alignment", method="align",
|
|
148
|
+
control={"force": force}))
|
|
149
|
+
fa_nodes[t] = fa_n
|
|
150
|
+
metas.append({"skipped": False, "seg_start": seg_start, "pseg_index": i,
|
|
151
|
+
"vad_node": vad_n, "fa_nodes": fa_nodes, "texts": nonempty})
|
|
152
|
+
return Composition(nodes=nodes), metas
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def decompose_source(
|
|
156
|
+
queue: JobQueue,
|
|
157
|
+
cfg: DecompConfig, # Run configuration
|
|
158
|
+
source: Dict[str, Any], # One source entry from the transcription manifest
|
|
159
|
+
source_index: int, # Position of this source within the run
|
|
160
|
+
transcribers: List[str], # Transcriber names (manifest order)
|
|
161
|
+
text_from: str, # Authoritative transcriber (layer-0 text)
|
|
162
|
+
) -> Tuple[str, List[DecompSegment]]: # (source_path, ordered aligned segments)
|
|
163
|
+
"""Decompose one source into aligned fine segments with per-transcriber variants.
|
|
164
|
+
|
|
165
|
+
The VAD-chunk skeleton is computed ONCE per pipeline segment (audio-only);
|
|
166
|
+
each transcriber's text is forced-aligned onto it independently, then the
|
|
167
|
+
pure fold merges per chunk: the `text_from` transcriber's alignment becomes
|
|
168
|
+
the authoritative text, every transcriber's chunk text + char range rides
|
|
169
|
+
`variants` (slice refs at commit). C4's "same skeleton, different text"
|
|
170
|
+
duplication is gone — agreement is stored once by construction.
|
|
171
|
+
"""
|
|
172
|
+
source_path = str(source.get("source_path", ""))
|
|
173
|
+
seg_list = list(source.get("segments") or [])
|
|
174
|
+
|
|
175
|
+
comp, metas = build_alignment_composition(
|
|
176
|
+
seg_list, cfg.vad_capability, cfg.fa_capability, transcribers, force=cfg.force)
|
|
177
|
+
results: Dict[str, Any] = {}
|
|
178
|
+
if comp.nodes:
|
|
179
|
+
comp_id = await queue.submit_composition(comp)
|
|
180
|
+
crun = await queue.wait_for_composition(comp_id)
|
|
181
|
+
if crun.status != NodeState.completed:
|
|
182
|
+
failed = {nid: str(nr.error) for nid, nr in crun.node_runs.items()
|
|
183
|
+
if nr.state == NodeState.failed}
|
|
184
|
+
raise RuntimeError(
|
|
185
|
+
f"alignment composition {crun.status.value} for {source_path}: {failed}")
|
|
186
|
+
results = crun.results_by_node()
|
|
187
|
+
|
|
188
|
+
aligned: List[DecompSegment] = []
|
|
189
|
+
global_index = 0
|
|
190
|
+
for m in metas:
|
|
191
|
+
seg_start = m["seg_start"]
|
|
192
|
+
if m["skipped"]:
|
|
193
|
+
logger.warning(f"[src {source_index}] no transcriber text at {seg_start:.1f}s; "
|
|
194
|
+
f"skipping pipeline segment")
|
|
195
|
+
continue
|
|
196
|
+
vad_chunks = vad_chunks_from_result(results[m["vad_node"]])
|
|
197
|
+
|
|
198
|
+
# Per-transcriber fold over the SHARED skeleton.
|
|
199
|
+
per_t_segments: Dict[str, List[Any]] = {}
|
|
200
|
+
for t, fa_n in m["fa_nodes"].items():
|
|
201
|
+
text = m["texts"][t]
|
|
202
|
+
fa_words = fa_words_from_result(results[fa_n])
|
|
203
|
+
spans = map_fa_words_to_text(text, fa_words)
|
|
204
|
+
assignments = assign_words_to_chunks(fa_words, vad_chunks)
|
|
205
|
+
per_t_segments[t] = build_segments_from_alignment(
|
|
206
|
+
text=text, spans=spans, assignments=assignments,
|
|
207
|
+
num_chunks=len(vad_chunks), source_provider_id=t,
|
|
208
|
+
)
|
|
209
|
+
auth = per_t_segments.get(text_from)
|
|
210
|
+
if auth is not None:
|
|
211
|
+
for w in tier1_alignment_checks(auth, vad_chunks):
|
|
212
|
+
logger.warning(f"[src {source_index}] pseg @ {seg_start:.1f}s [{text_from}]: {w}")
|
|
213
|
+
else:
|
|
214
|
+
logger.warning(f"[src {source_index}] pseg @ {seg_start:.1f}s: authoritative "
|
|
215
|
+
f"transcriber {text_from!r} has no text here — layer-0 text empty")
|
|
216
|
+
|
|
217
|
+
for k, vc in enumerate(vad_chunks):
|
|
218
|
+
variants = []
|
|
219
|
+
for t in transcribers:
|
|
220
|
+
tsegs = per_t_segments.get(t)
|
|
221
|
+
if tsegs is None:
|
|
222
|
+
continue
|
|
223
|
+
ts = tsegs[k]
|
|
224
|
+
if ts.text:
|
|
225
|
+
variants.append(SegmentVariant(transcriber=t, text=ts.text,
|
|
226
|
+
start_char=ts.start_char, end_char=ts.end_char))
|
|
227
|
+
aligned.append(DecompSegment(
|
|
228
|
+
index=global_index,
|
|
229
|
+
text=(auth[k].text if auth is not None else ""),
|
|
230
|
+
start_time=seg_start + vc.start_time, end_time=seg_start + vc.end_time,
|
|
231
|
+
chunk_start=vc.start_time, chunk_end=vc.end_time,
|
|
232
|
+
vad_chunk_index=vc.index, pseg_index=m["pseg_index"],
|
|
233
|
+
variants=variants,
|
|
234
|
+
))
|
|
235
|
+
global_index += 1
|
|
236
|
+
logger.info(f"[src {source_index}] pseg @ {seg_start:.1f}s -> "
|
|
237
|
+
f"{len(vad_chunks)} chunks, {len(m['fa_nodes'])} transcriber alignment(s)")
|
|
238
|
+
return source_path, aligned
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def confirm_seam(
|
|
242
|
+
seam: str, # Seam label, e.g. "alignment-review"
|
|
243
|
+
summary_lines: List[str], # What the operator is being asked to accept
|
|
244
|
+
warnings: List[str], # Tier-1 warnings (logged prominently)
|
|
245
|
+
assume_yes: bool = False, # Headless mode: accept without prompting
|
|
246
|
+
) -> bool: # True = proceed, False = operator aborted
|
|
247
|
+
"""HITL approval seam in its cheapest viable form (log + optional CLI prompt).
|
|
248
|
+
|
|
249
|
+
Per-seam HITL-assist annotation (5 fields):
|
|
250
|
+
1. signal: per-document summary + Tier-1 warnings
|
|
251
|
+
2. deterministic pre-filter: tier1_alignment_checks (no AI)
|
|
252
|
+
3. modality-bridge candidate: spectrogram / word-overlay review (future Tier 2)
|
|
253
|
+
4. authoritative verifier: re-align or re-transcribe-and-compare (future Tier 3)
|
|
254
|
+
5. flywheel capture: accept/abort decisions logged; durable capture is a
|
|
255
|
+
pass-2 seam-contract concern, not solved here
|
|
256
|
+
|
|
257
|
+
NOTE: input() blocks the event loop — acceptable because seams sit between
|
|
258
|
+
stages with no jobs in flight; the pass-2 seam contract needs an async shape.
|
|
259
|
+
"""
|
|
260
|
+
for line in summary_lines:
|
|
261
|
+
logger.info(f"[{seam}] {line}")
|
|
262
|
+
for w in warnings:
|
|
263
|
+
logger.warning(f"[{seam}] {w}")
|
|
264
|
+
if assume_yes:
|
|
265
|
+
logger.info(f"[{seam}] auto-accepted (assume_yes)")
|
|
266
|
+
return True
|
|
267
|
+
reply = input(f"[{seam}] proceed? [Y/n] ").strip().lower()
|
|
268
|
+
accepted = reply in ("", "y", "yes")
|
|
269
|
+
logger.info(f"[{seam}] {'accepted' if accepted else 'ABORTED'} by operator")
|
|
270
|
+
return accepted
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def collect_capability_info(
|
|
274
|
+
manager: CapabilityManager, # Manager holding the loaded capabilities
|
|
275
|
+
instance_ids: List[str], # Instance ids to record
|
|
276
|
+
) -> Dict[str, Dict[str, Any]]: # instance_id -> {name, version, db_path, config_hash}
|
|
277
|
+
"""Record capability identity + data-DB pointers for the run manifest (provenance).
|
|
278
|
+
|
|
279
|
+
Stage 5: also records each capability's EFFECTIVE config hash (the same
|
|
280
|
+
`compute_config_hash` the empirical store keys on) — the VAD config hash is
|
|
281
|
+
a Segment identity input. `db_path` prefers the effective config over the
|
|
282
|
+
manifest default (a --graph-db-path override must be what downstream cores
|
|
283
|
+
resolve; the D19 lesson). Stage 6 (0.2.1): the EFFECTIVE config dict is
|
|
284
|
+
recorded READABLY beside its hash (the I8 lesson).
|
|
285
|
+
"""
|
|
286
|
+
info: Dict[str, Dict[str, Any]] = {}
|
|
287
|
+
for iid in instance_ids:
|
|
288
|
+
meta = (getattr(manager, "capabilities", {}) or {}).get(iid)
|
|
289
|
+
if meta is None:
|
|
290
|
+
continue
|
|
291
|
+
manifest = getattr(meta, "manifest", {}) or {}
|
|
292
|
+
current_config: Dict[str, Any] = {}
|
|
293
|
+
try:
|
|
294
|
+
proxy = manager.get_capability(iid)
|
|
295
|
+
if proxy is not None:
|
|
296
|
+
current_config = proxy.get_current_config() or {}
|
|
297
|
+
except Exception as e: # Best-effort: identity recording must not fail the run
|
|
298
|
+
logger.warning(f"collect_capability_info: get_current_config({iid}) failed: {e}")
|
|
299
|
+
info[iid] = {
|
|
300
|
+
"name": meta.name,
|
|
301
|
+
"version": getattr(meta, "version", None),
|
|
302
|
+
"db_path": current_config.get("db_path") or manifest.get("db_path"),
|
|
303
|
+
"config_hash": compute_config_hash(current_config),
|
|
304
|
+
"config": current_config,
|
|
305
|
+
}
|
|
306
|
+
return info
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _journal_run_event(
|
|
310
|
+
manager: CapabilityManager, # Manager owning the journal store
|
|
311
|
+
event_type: str, # SubstrateEventType value (run_started / run_finished / verify_outcome)
|
|
312
|
+
run_id: str, # This run's manifest id
|
|
313
|
+
actor: Optional[str], # Who/what initiated the run
|
|
314
|
+
payload: Dict[str, Any], # Run-level structured detail
|
|
315
|
+
) -> None:
|
|
316
|
+
"""Append a host-tier run event to the journal (CR-14 follow-up).
|
|
317
|
+
|
|
318
|
+
The cores are the trusted host writer class: RUN_STARTED/RUN_FINISHED
|
|
319
|
+
bracket the run (run manifest <-> journal linkage by run_id) and
|
|
320
|
+
VERIFY_OUTCOME makes skeptical-lens results durable rows (I14). No-op
|
|
321
|
+
when the manager has no journal store; append failures stay LOUD.
|
|
322
|
+
"""
|
|
323
|
+
journal = getattr(manager, "journal_store", None)
|
|
324
|
+
if journal is None:
|
|
325
|
+
return
|
|
326
|
+
journal.append(JournalEvent(
|
|
327
|
+
event_type=event_type, run_id=run_id, actor=actor, payload=payload))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
async def run_decomp(
|
|
331
|
+
manager: CapabilityManager, # Manager with VAD + FA + graph capabilities loaded
|
|
332
|
+
queue: JobQueue, # Started job queue
|
|
333
|
+
cfg: DecompConfig, # Run configuration
|
|
334
|
+
source_manifest_path: str, # Transcription run manifest to decompose
|
|
335
|
+
run_id: Optional[str] = None, # Override run id (default: generated)
|
|
336
|
+
actor: Optional[str] = None, # Who/what initiated (journal attribution; CLI default cli:<user>)
|
|
337
|
+
) -> DecompManifest: # Manifest of the sources extended
|
|
338
|
+
"""Extend every source in a transcription run manifest with its fine spine.
|
|
339
|
+
|
|
340
|
+
Stage 5 (decomp-as-extender): the graph ROOT must already exist — decomp
|
|
341
|
+
recomputes the deterministic root ids from the manifest, verifies the
|
|
342
|
+
Source node is present (loud error pointing at transcription emission
|
|
343
|
+
otherwise), aligns per transcriber, and attaches the fine spine under the
|
|
344
|
+
run's AudioRendition (PART_OF rendition) via the layer's idempotent
|
|
345
|
+
`extend_graph` (re-runs verify-collide). The fold is declared as a
|
|
346
|
+
`Derivation` event when segments were actually created.
|
|
347
|
+
"""
|
|
348
|
+
run_id = run_id or new_run_id()
|
|
349
|
+
# CR-14 follow-up: queue-scoped run context — every job submitted in this
|
|
350
|
+
# run carries run_id/actor into its journal rows + worker diagnostics
|
|
351
|
+
# (run-manifest <-> journal linkage); the run itself is bracketed by
|
|
352
|
+
# RUN_STARTED/RUN_FINISHED host-tier rows.
|
|
353
|
+
queue.set_run_context(run_id=run_id, actor=actor)
|
|
354
|
+
_journal_run_event(manager, SubstrateEventType.RUN_STARTED.value, run_id, actor, {
|
|
355
|
+
"core": "cjm-transcript-decomp-core",
|
|
356
|
+
"source_manifest": str(source_manifest_path),
|
|
357
|
+
"graph_capability": cfg.graph_capability,
|
|
358
|
+
})
|
|
359
|
+
src = load_source_manifest(source_manifest_path)
|
|
360
|
+
src_cfg = src.get("config", {}) or {}
|
|
361
|
+
transcribers = list(src_cfg.get("transcriber_capabilities") or [])
|
|
362
|
+
if not transcribers and src_cfg.get("transcriber_capability"):
|
|
363
|
+
# pre-0.2.0 manifest: single transcriber under the old key
|
|
364
|
+
transcribers = [str(src_cfg["transcriber_capability"])]
|
|
365
|
+
if not transcribers:
|
|
366
|
+
raise RuntimeError("source manifest lists no transcribers")
|
|
367
|
+
text_from = cfg.text_from or (transcribers[0] if len(transcribers) == 1 else None)
|
|
368
|
+
if text_from is None:
|
|
369
|
+
raise RuntimeError(
|
|
370
|
+
f"multi-transcriber manifest ({transcribers}) requires --text-from "
|
|
371
|
+
"(the authoritative transcriber for layer-0 text)")
|
|
372
|
+
if text_from not in transcribers:
|
|
373
|
+
raise RuntimeError(f"--text-from {text_from!r} not among the manifest's transcribers {transcribers}")
|
|
374
|
+
src_capabilities = src.get("capabilities", {}) or {}
|
|
375
|
+
|
|
376
|
+
manifest = DecompManifest(
|
|
377
|
+
run_id=run_id, created_at=time.time(), config=cfg.to_dict(),
|
|
378
|
+
source_manifest=str(source_manifest_path),
|
|
379
|
+
source_format=src.get("format", ""), source_version=src.get("version", ""),
|
|
380
|
+
capabilities=collect_capability_info(manager, [cfg.vad_capability, cfg.fa_capability, cfg.graph_capability]),
|
|
381
|
+
)
|
|
382
|
+
vad_config_hash = str((manifest.capabilities.get(cfg.vad_capability) or {}).get("config_hash") or "")
|
|
383
|
+
|
|
384
|
+
sources = src.get("sources", []) or []
|
|
385
|
+
status = "completed"
|
|
386
|
+
try:
|
|
387
|
+
for i, source in enumerate(sources):
|
|
388
|
+
# Extender pre-check: the transcription-emitted root must exist.
|
|
389
|
+
roots = resolve_root_ids(source, src_capabilities)
|
|
390
|
+
root = await graph_task(queue, cfg.graph_capability, "get_node", node_id=roots["source"])
|
|
391
|
+
if root is None:
|
|
392
|
+
raise RuntimeError(
|
|
393
|
+
f"Source root {roots['source']} not found in the graph for "
|
|
394
|
+
f"{source.get('source_path')!r} — the graph begins at transcription: "
|
|
395
|
+
"re-run cjm-transcription-core with --graph-capability against this DB first")
|
|
396
|
+
|
|
397
|
+
source_path, aligned = await decompose_source(
|
|
398
|
+
queue, cfg, source, i, transcribers, text_from)
|
|
399
|
+
title = Path(source_path).stem or f"source-{i}"
|
|
400
|
+
|
|
401
|
+
empty = sum(1 for a in aligned if not a.text.strip())
|
|
402
|
+
warns = [f"{empty}/{len(aligned)} aligned segment(s) have empty layer-0 text"] if empty else []
|
|
403
|
+
if not confirm_seam("alignment-review",
|
|
404
|
+
[f"{title}: {len(aligned)} aligned segment(s), text_from={text_from}"],
|
|
405
|
+
warns, assume_yes=cfg.assume_yes):
|
|
406
|
+
logger.warning(f"run {run_id}: aborted at source {i} ({source_path})")
|
|
407
|
+
status = "aborted"
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
nodes, edges, ids = build_extension_payload(
|
|
411
|
+
source, src_capabilities, vad_config_hash, text_from, aligned)
|
|
412
|
+
|
|
413
|
+
if not confirm_seam("commit-review",
|
|
414
|
+
[f"{title}: extending Source {ids['source'][:8]}… with "
|
|
415
|
+
f"{len(ids['segments'])} segment node(s) + {len(edges)} edge(s) "
|
|
416
|
+
f"via {cfg.graph_capability}"],
|
|
417
|
+
[], assume_yes=cfg.assume_yes):
|
|
418
|
+
logger.warning(f"run {run_id}: commit declined at source {i} ({source_path})")
|
|
419
|
+
status = "aborted"
|
|
420
|
+
break
|
|
421
|
+
|
|
422
|
+
res = await extend_graph(queue, cfg.graph_capability, nodes, edges)
|
|
423
|
+
logger.info(f"[src {i}] extension: +{res.nodes_added} node(s) "
|
|
424
|
+
f"({res.nodes_verified} verified present), +{res.edges_added} edge(s) "
|
|
425
|
+
f"({res.edges_existing} existing)")
|
|
426
|
+
if res.nodes_added:
|
|
427
|
+
d = Derivation(
|
|
428
|
+
actor="host:cjm-transcript-decomp-core", method="alignment-fold/v1",
|
|
429
|
+
input_ids=ids["transcripts_used"], output_ids=[ids["source"]],
|
|
430
|
+
properties={"run_id": run_id, "segments": len(ids["segments"]),
|
|
431
|
+
"text_from": text_from},
|
|
432
|
+
)
|
|
433
|
+
dn, de = derivation_to_graph(d)
|
|
434
|
+
await extend_graph(queue, cfg.graph_capability, [dn], de)
|
|
435
|
+
|
|
436
|
+
# Fine spine hangs under the run's renditions — verify scopes there.
|
|
437
|
+
vr = await verify_source(queue, cfg.graph_capability, ids["source"], ids["renditions"])
|
|
438
|
+
if vr is None:
|
|
439
|
+
logger.error(f"[src {i}] verify: Source {ids['source']} NOT FOUND in graph")
|
|
440
|
+
else:
|
|
441
|
+
logger.info(f"[src {i}] verify: {'OK' if vr.ok else 'FAILED'} "
|
|
442
|
+
f"(asegs={vr.audio_segment_count}, renditions={vr.rendition_count}, "
|
|
443
|
+
f"segments={vr.segment_count}, "
|
|
444
|
+
f"src_starts={vr.source_starts_with}, aseg_next={vr.aseg_next_complete}, "
|
|
445
|
+
f"seg_next={vr.seg_next_complete}, part_of={vr.part_of_complete}, "
|
|
446
|
+
f"rend_starts={vr.rendition_starts_with_complete}, "
|
|
447
|
+
f"timing={vr.all_have_timing}, sources={vr.all_have_sources})")
|
|
448
|
+
|
|
449
|
+
# I14: verify outcomes are journal ROWS (absence of a log line is not
|
|
450
|
+
# green); the full check vector rides the payload.
|
|
451
|
+
_journal_run_event(manager, SubstrateEventType.VERIFY_OUTCOME.value, run_id, actor, {
|
|
452
|
+
"core": "cjm-transcript-decomp-core",
|
|
453
|
+
"source_node_id": ids["source"],
|
|
454
|
+
"found": vr is not None,
|
|
455
|
+
"ok": (vr.ok if vr is not None else False),
|
|
456
|
+
"checks": (asdict(vr) if vr is not None else None),
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
manifest.sources.append(DecompSourceRecord(
|
|
460
|
+
source_node_id=ids["source"], source_path=source_path, title=title,
|
|
461
|
+
segment_count=len(ids["segments"]), segment_ids=ids["segments"]))
|
|
462
|
+
except BaseException as e:
|
|
463
|
+
# The journal exists for exactly this row: a run that DIED records
|
|
464
|
+
# how far it got (failures stop being the unattributed case).
|
|
465
|
+
_journal_run_event(manager, SubstrateEventType.RUN_FINISHED.value, run_id, actor, {
|
|
466
|
+
"core": "cjm-transcript-decomp-core", "status": "failed", "error": repr(e),
|
|
467
|
+
"sources_completed": len(manifest.sources), "sources_total": len(sources),
|
|
468
|
+
})
|
|
469
|
+
raise
|
|
470
|
+
_journal_run_event(manager, SubstrateEventType.RUN_FINISHED.value, run_id, actor, {
|
|
471
|
+
"core": "cjm-transcript-decomp-core", "status": status,
|
|
472
|
+
"sources_completed": len(manifest.sources), "sources_total": len(sources),
|
|
473
|
+
"segments": sum(s.segment_count for s in manifest.sources),
|
|
474
|
+
})
|
|
475
|
+
return manifest
|