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 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from cjm_transcript_decomp_core.models import FAWord, TextSegment, VADChunk
|
|
7
|
+
|
|
8
|
+
# Strip punctuation for comparison (matches what FA models strip).
|
|
9
|
+
_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _strip_punct(
|
|
13
|
+
text: str, # Text to normalize
|
|
14
|
+
) -> str: # Text with punctuation removed
|
|
15
|
+
"""Strip punctuation from text for comparison with FA output."""
|
|
16
|
+
return _PUNCT_RE.sub("", text)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def map_fa_words_to_text(
|
|
20
|
+
text: str, # Original text with punctuation
|
|
21
|
+
fa_items: List[FAWord], # FA word-level alignment results
|
|
22
|
+
) -> List[Tuple[int, int]]: # (start_char, end_char) spans into the original text
|
|
23
|
+
"""Map forced-alignment words back to character spans in the original text.
|
|
24
|
+
|
|
25
|
+
Walks the original text, matching each FA word (punctuation-stripped) against
|
|
26
|
+
original-text tokens; returns character offset pairs for each FA word.
|
|
27
|
+
"""
|
|
28
|
+
spans = []
|
|
29
|
+
pos = 0 # Current position in original text
|
|
30
|
+
|
|
31
|
+
for item in fa_items:
|
|
32
|
+
fa_word = item.text.lower()
|
|
33
|
+
|
|
34
|
+
# Skip whitespace to find next token start
|
|
35
|
+
while pos < len(text) and text[pos].isspace():
|
|
36
|
+
pos += 1
|
|
37
|
+
if pos >= len(text):
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
# Find the end of the current token (run of non-space characters)
|
|
41
|
+
token_start = pos
|
|
42
|
+
token_end = pos
|
|
43
|
+
while token_end < len(text) and not text[token_end].isspace():
|
|
44
|
+
token_end += 1
|
|
45
|
+
|
|
46
|
+
orig_token = text[token_start:token_end]
|
|
47
|
+
stripped_token = _strip_punct(orig_token).lower()
|
|
48
|
+
|
|
49
|
+
if stripped_token == fa_word:
|
|
50
|
+
spans.append((token_start, token_end))
|
|
51
|
+
pos = token_end
|
|
52
|
+
else:
|
|
53
|
+
# Multi-token FA words (e.g. "p.m." -> "pm") or punctuation-split tokens:
|
|
54
|
+
# consume up to 3 extra tokens until the stripped concatenation matches.
|
|
55
|
+
concat = stripped_token
|
|
56
|
+
scan_end = token_end
|
|
57
|
+
matched = False
|
|
58
|
+
for _ in range(3):
|
|
59
|
+
if concat.lower() == fa_word:
|
|
60
|
+
spans.append((token_start, scan_end))
|
|
61
|
+
pos = scan_end
|
|
62
|
+
matched = True
|
|
63
|
+
break
|
|
64
|
+
while scan_end < len(text) and text[scan_end].isspace():
|
|
65
|
+
scan_end += 1
|
|
66
|
+
if scan_end >= len(text):
|
|
67
|
+
break
|
|
68
|
+
next_start = scan_end
|
|
69
|
+
while scan_end < len(text) and not text[scan_end].isspace():
|
|
70
|
+
scan_end += 1
|
|
71
|
+
concat += _strip_punct(text[next_start:scan_end])
|
|
72
|
+
|
|
73
|
+
if not matched:
|
|
74
|
+
if concat.lower() == fa_word:
|
|
75
|
+
spans.append((token_start, scan_end))
|
|
76
|
+
pos = scan_end
|
|
77
|
+
else:
|
|
78
|
+
# Fallback: take the single token and move on (handles
|
|
79
|
+
# insertions/deletions between transcript and FA output).
|
|
80
|
+
spans.append((token_start, token_end))
|
|
81
|
+
pos = token_end
|
|
82
|
+
|
|
83
|
+
return spans
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def assign_words_to_chunks(
|
|
87
|
+
fa_items: List[FAWord], # FA word-level alignment results
|
|
88
|
+
vad_chunks: List[VADChunk], # VAD chunks with start/end times
|
|
89
|
+
) -> List[int]: # Chunk index for each FA word
|
|
90
|
+
"""Assign each FA word to a VAD chunk by timestamp overlap.
|
|
91
|
+
|
|
92
|
+
Words whose start_time falls within a chunk's [start, end] are assigned to
|
|
93
|
+
that chunk; words in silence gaps go to the nearest chunk by time proximity.
|
|
94
|
+
"""
|
|
95
|
+
if not vad_chunks:
|
|
96
|
+
return [0] * len(fa_items)
|
|
97
|
+
|
|
98
|
+
assignments = []
|
|
99
|
+
for item in fa_items:
|
|
100
|
+
t = item.start_time
|
|
101
|
+
best_idx = 0
|
|
102
|
+
best_dist = float("inf")
|
|
103
|
+
for i, chunk in enumerate(vad_chunks):
|
|
104
|
+
if chunk.start_time <= t <= chunk.end_time:
|
|
105
|
+
best_idx = i
|
|
106
|
+
break
|
|
107
|
+
dist = min(abs(t - chunk.start_time), abs(t - chunk.end_time))
|
|
108
|
+
if dist < best_dist:
|
|
109
|
+
best_dist = dist
|
|
110
|
+
best_idx = i
|
|
111
|
+
assignments.append(best_idx)
|
|
112
|
+
return assignments
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_segments_from_alignment(
|
|
116
|
+
text: str, # Original text with punctuation
|
|
117
|
+
spans: List[Tuple[int, int]], # Character spans from map_fa_words_to_text
|
|
118
|
+
assignments: List[int], # Chunk index per word from assign_words_to_chunks
|
|
119
|
+
num_chunks: int, # Total number of VAD chunks
|
|
120
|
+
source_id: Optional[str] = None, # Source row id for traceability
|
|
121
|
+
source_provider_id: Optional[str] = None, # Source provider identifier
|
|
122
|
+
) -> List[TextSegment]: # One segment per VAD chunk
|
|
123
|
+
"""Build a TextSegment per VAD chunk by grouping words by chunk assignment.
|
|
124
|
+
|
|
125
|
+
Each chunk's text is the original (punctuated) slice from the first to the
|
|
126
|
+
last word assigned to it; chunks with no words become empty segments.
|
|
127
|
+
"""
|
|
128
|
+
chunk_spans: Dict[int, List[Tuple[int, int]]] = {}
|
|
129
|
+
for span, chunk_idx in zip(spans, assignments):
|
|
130
|
+
chunk_spans.setdefault(chunk_idx, []).append(span)
|
|
131
|
+
|
|
132
|
+
segments = []
|
|
133
|
+
for chunk_idx in range(num_chunks):
|
|
134
|
+
word_spans = chunk_spans.get(chunk_idx, [])
|
|
135
|
+
if word_spans:
|
|
136
|
+
seg_start = word_spans[0][0]
|
|
137
|
+
seg_end = word_spans[-1][1]
|
|
138
|
+
seg_text = text[seg_start:seg_end].strip()
|
|
139
|
+
else:
|
|
140
|
+
seg_text = ""
|
|
141
|
+
seg_start = None
|
|
142
|
+
seg_end = None
|
|
143
|
+
segments.append(TextSegment(
|
|
144
|
+
index=chunk_idx, text=seg_text,
|
|
145
|
+
source_id=source_id, source_provider_id=source_provider_id,
|
|
146
|
+
start_char=seg_start, end_char=seg_end,
|
|
147
|
+
))
|
|
148
|
+
return segments
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def tier1_alignment_checks(
|
|
152
|
+
segments: List[TextSegment], # Segments produced by build_segments_from_alignment
|
|
153
|
+
vad_chunks: List[VADChunk], # The VAD chunks they were aligned to
|
|
154
|
+
) -> List[str]: # Human-readable warnings (empty = all clear)
|
|
155
|
+
"""Tier-1 deterministic pre-filters for the alignment-review seam (no AI)."""
|
|
156
|
+
warnings: List[str] = []
|
|
157
|
+
if len(segments) != len(vad_chunks):
|
|
158
|
+
warnings.append(
|
|
159
|
+
f"segment/chunk count mismatch: {len(segments)} vs {len(vad_chunks)}"
|
|
160
|
+
)
|
|
161
|
+
empty = sum(1 for s in segments if not s.text.strip())
|
|
162
|
+
if empty:
|
|
163
|
+
warnings.append(
|
|
164
|
+
f"{empty}/{len(segments)} segment(s) have EMPTY text (VAD chunk with no aligned words)"
|
|
165
|
+
)
|
|
166
|
+
return warnings
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The CLI driver — the decomposition core's first (and currently only) frontend.
|
|
2
|
+
|
|
3
|
+
Ships in-package as the `cjm-transcript-decomp-core` console script so the driver
|
|
4
|
+
can never skew from the core. GUI presentation drivers come later and consume the
|
|
5
|
+
same `pipeline` module; they never reimplement it (CLI-first / headless-core
|
|
6
|
+
principle).
|
|
7
|
+
|
|
8
|
+
Prerequisite runtime (once, from the repo root):
|
|
9
|
+
|
|
10
|
+
cjm-ctl --cjm-config cjm.yaml setup-runtime
|
|
11
|
+
cjm-ctl --cjm-config cjm.yaml install-all --capabilities capabilities_test.yaml --force
|
|
12
|
+
|
|
13
|
+
Then decompose a transcription-core run manifest:
|
|
14
|
+
|
|
15
|
+
cjm-transcript-decomp-core run path/to/transcription-run.json --yes
|
|
16
|
+
# GPU runs: opt into CR-7 GPU subtree attribution
|
|
17
|
+
cjm-transcript-decomp-core run run.json --yes --sysmon-capability cjm-capability-monitor-nvidia
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import asyncio
|
|
22
|
+
import getpass
|
|
23
|
+
import logging
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
from cjm_substrate.core.manager import CapabilityManager
|
|
28
|
+
from cjm_substrate.core.queue import JobQueue
|
|
29
|
+
from cjm_transcript_decomp_core.models import DecompConfig
|
|
30
|
+
from cjm_transcript_decomp_core.pipeline import load_source_manifest, run_decomp
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_parser() -> argparse.ArgumentParser: # Configured CLI parser
|
|
36
|
+
"""Build the CLI parser (subcommands: run)."""
|
|
37
|
+
parser = argparse.ArgumentParser(
|
|
38
|
+
prog="cjm-transcript-decomp-core",
|
|
39
|
+
description="Headless transcript decomposition: VAD + forced alignment -> fine-spine graph extension.",
|
|
40
|
+
)
|
|
41
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
42
|
+
|
|
43
|
+
run = sub.add_parser("run", help="Extend a transcription-emitted graph root with the fine spine")
|
|
44
|
+
run.add_argument("manifest", help="Transcription-core run manifest JSON (the source to decompose)")
|
|
45
|
+
run.add_argument("--manifests-dir", default=".cjm/manifests", help="Capability manifests directory")
|
|
46
|
+
run.add_argument("--vad-capability", default="cjm-capability-silero-vad", help="VAD capability name")
|
|
47
|
+
run.add_argument("--fa-capability", default="cjm-capability-qwen3-forced-aligner", help="Forced-alignment capability name")
|
|
48
|
+
run.add_argument("--graph-capability", default="cjm-capability-graph-sqlite", help="Graph-storage capability name")
|
|
49
|
+
run.add_argument("--graph-db-path", default=None,
|
|
50
|
+
help="Explicit graph DB path override (F10; scratch-graph loop-backs; default: the capability's configured db_path)")
|
|
51
|
+
run.add_argument("--text-from", default=None,
|
|
52
|
+
help="Authoritative transcriber for layer-0 text (the ACCURACY model; "
|
|
53
|
+
"required when the manifest carries multiple transcribers; "
|
|
54
|
+
"default: the manifest's sole transcriber)")
|
|
55
|
+
run.add_argument("--sysmon-capability", default=None, help="monitor capability for GPU subtree attribution (CR-7); loaded first; default: no monitor")
|
|
56
|
+
run.add_argument("--language", default="English", help="Forced-alignment language")
|
|
57
|
+
run.add_argument("--force", action="store_true", help="Bypass capability-side caches (VAD + FA)")
|
|
58
|
+
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: runs/<run_id>.json)")
|
|
60
|
+
run.add_argument("--actor", default=None,
|
|
61
|
+
help="Journal attribution for who/what initiated this run (default: cli:<username>)")
|
|
62
|
+
run.add_argument("-v", "--verbose", action="store_true", help="DEBUG-level logging")
|
|
63
|
+
return parser
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_capabilities(
|
|
67
|
+
manager: CapabilityManager, # Freshly constructed manager
|
|
68
|
+
instance_ids: List[str], # Capability names to load (default instances), in order
|
|
69
|
+
configs: Optional[Dict[str, Dict[str, Any]]] = None, # Per-capability config overrides (caller-wins, C8)
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Discover manifests + load each requested capability (default instance)."""
|
|
72
|
+
manager.discover_manifests()
|
|
73
|
+
discovered = {m.name: m for m in manager.discovered}
|
|
74
|
+
for iid in instance_ids:
|
|
75
|
+
meta = discovered.get(iid)
|
|
76
|
+
if meta is None:
|
|
77
|
+
raise SystemExit(
|
|
78
|
+
f"capability {iid!r} not found in manifests "
|
|
79
|
+
f"(discovered: {sorted(discovered)}) — run cjm-ctl install-all first"
|
|
80
|
+
)
|
|
81
|
+
if not manager.load_capability(meta, config=(configs or {}).get(iid)):
|
|
82
|
+
raise SystemExit(f"failed to load capability {iid!r}")
|
|
83
|
+
logger.info(f"loaded {iid}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def run_command(
|
|
87
|
+
args: argparse.Namespace, # Parsed CLI arguments for the `run` subcommand
|
|
88
|
+
) -> int: # Process exit code (0 = all sources extended + verified)
|
|
89
|
+
"""Execute the `run` subcommand: extend a transcription-emitted root with the fine spine."""
|
|
90
|
+
manifest_path = str(Path(args.manifest).resolve())
|
|
91
|
+
if not Path(manifest_path).exists():
|
|
92
|
+
raise SystemExit(f"source manifest not found: {manifest_path}")
|
|
93
|
+
|
|
94
|
+
cfg = DecompConfig(
|
|
95
|
+
vad_capability=args.vad_capability,
|
|
96
|
+
fa_capability=args.fa_capability,
|
|
97
|
+
graph_capability=args.graph_capability,
|
|
98
|
+
text_from=args.text_from,
|
|
99
|
+
language=args.language,
|
|
100
|
+
force=args.force,
|
|
101
|
+
assume_yes=args.yes,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# CR-7 GPU subtree attribution is opt-in: --sysmon-capability threads the monitor
|
|
105
|
+
# name into BOTH the manager and the queue; the monitor loads FIRST so GPU
|
|
106
|
+
# capabilities' samples record gpu_memory_mb_peak (voxtral-vllm e2e pattern).
|
|
107
|
+
manager = CapabilityManager(
|
|
108
|
+
search_paths=[Path(args.manifests_dir)],
|
|
109
|
+
sysmon_capability_name=args.sysmon_capability,
|
|
110
|
+
)
|
|
111
|
+
instance_ids = [cfg.vad_capability, cfg.fa_capability, cfg.graph_capability]
|
|
112
|
+
load_order = ([args.sysmon_capability] if args.sysmon_capability else []) + instance_ids
|
|
113
|
+
# F10: --graph-db-path threads a caller-wins config into the graph load
|
|
114
|
+
# (the C8 pattern correction-core already used; scratch-graph loop-backs).
|
|
115
|
+
configs = ({cfg.graph_capability: {"db_path": args.graph_db_path}}
|
|
116
|
+
if args.graph_db_path else None)
|
|
117
|
+
load_capabilities(manager, load_order, configs=configs)
|
|
118
|
+
|
|
119
|
+
queue = JobQueue(deps=manager, sysmon_capability_name=args.sysmon_capability)
|
|
120
|
+
await queue.start()
|
|
121
|
+
try:
|
|
122
|
+
# CR-14 follow-up: actor attribution (operator identity by default;
|
|
123
|
+
# agents/services pass --actor explicitly).
|
|
124
|
+
actor = args.actor or f"cli:{getpass.getuser()}"
|
|
125
|
+
manifest = await run_decomp(manager, queue, cfg, manifest_path, actor=actor)
|
|
126
|
+
finally:
|
|
127
|
+
await queue.stop()
|
|
128
|
+
for iid in reversed(load_order): # Reverse load order; the monitor unloads last
|
|
129
|
+
try:
|
|
130
|
+
manager.unload_capability(iid)
|
|
131
|
+
except Exception as e: # Best-effort teardown; never mask the run's outcome
|
|
132
|
+
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
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main(
|
|
145
|
+
argv: Optional[List[str]] = None, # Argument list override (None = sys.argv)
|
|
146
|
+
) -> int: # Process exit code
|
|
147
|
+
"""CLI entry point (console script: `cjm-transcript-decomp-core`)."""
|
|
148
|
+
args = build_parser().parse_args(argv)
|
|
149
|
+
logging.basicConfig(
|
|
150
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
151
|
+
format="%(asctime)s [%(levelname)s] %(name)s :: %(message)s",
|
|
152
|
+
)
|
|
153
|
+
if args.command == "run":
|
|
154
|
+
return asyncio.run(run_command(args))
|
|
155
|
+
raise SystemExit(f"unknown command: {args.command}")
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Graph-spine EXTENSION + skeptical-lens verification (stage 5, CR-18 revolution 2). Decomp no longer creates a Document: it RECOMPUTES the transcription-emitted root's deterministic node ids from the consumed manifest (no search), verifies the root exists, and attaches the fine Segment spine under the existing AudioSegment nodes — PART_OF to the owning rendition, STARTS_WITH per rendition (the coarse-seam jump anchor), source-wide NEXT. Each Segment carries the audio TimeSlice ref plus per-transcriber CharSlice refs into the Transcript nodes (the D4/P10 framing, finally expressible). Commit goes through the layer's idempotent extend_graph."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from cjm_context_graph_layer.grammar import grouped_spine_edges, SpineRelations
|
|
7
|
+
from cjm_context_graph_layer.ops import graph_task
|
|
8
|
+
from cjm_context_graph_primitives.graph import GraphNode
|
|
9
|
+
from cjm_context_graph_primitives.provenance import SourceRef
|
|
10
|
+
from cjm_context_graph_primitives.query import (EdgeQuery, EdgeQueryResult, NodeQuery,
|
|
11
|
+
NodeQueryResult, OrderBy, PropertyPredicate,
|
|
12
|
+
RelationPredicate)
|
|
13
|
+
from cjm_substrate.core.queue import JobQueue
|
|
14
|
+
from cjm_transcript_decomp_core.models import DecompSegment
|
|
15
|
+
from cjm_transcript_graph_schema.schema import (audio_rendition_node_id, audio_segment_node_id,
|
|
16
|
+
SegmentNode, source_node_id, transcript_node_id,
|
|
17
|
+
TranscriptGraphLabels, TranscriptSliceRef)
|
|
18
|
+
|
|
19
|
+
# Stage 4: typed query expressions + results — importing the result classes IS
|
|
20
|
+
# the host-side wire registration (F8); the tuple keeps these SIDE-EFFECT
|
|
21
|
+
# imports referenced so the canonical emit cannot prune them. Stage 5: the
|
|
22
|
+
# graph-aware layer owns the shared plumbing (graph_task is THE shared copy
|
|
23
|
+
# now) and the fractal spine grammar; the audio-transcript schema owns the
|
|
24
|
+
# node nouns + deterministic identity tuples. cjm_graph_domains is GONE
|
|
25
|
+
# (Document dissolved).
|
|
26
|
+
_REGISTERED_WIRE_KINDS = (NodeQueryResult, EdgeQueryResult)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_root_ids(
|
|
30
|
+
source_entry: Dict[str, Any], # One source entry from the transcription manifest (0.3.0)
|
|
31
|
+
capabilities_info: Dict[str, Dict[str, Any]], # The transcription manifest's capabilities block (config hashes)
|
|
32
|
+
) -> Dict[str, Any]: # {"source", "chain", "audio_segments": [{audio_segment, rendition, start, end, model_input_hash, transcripts}]}
|
|
33
|
+
"""Recompute the transcription-emitted root node ids from manifest data.
|
|
34
|
+
|
|
35
|
+
Deterministic identity makes the extender lookup a RECOMPUTATION, not a
|
|
36
|
+
search: Source = content hash; AudioSegment = (source, boundary range);
|
|
37
|
+
AudioRendition = (audio segment, preprocessing chain) — the per-source
|
|
38
|
+
`chain` ([] = raw convert-only) carried by the 0.3.0 manifest; Transcript =
|
|
39
|
+
(rendition, transcriber, config_hash). No stored-id coupling between the
|
|
40
|
+
cores. A pre-0.3.0 manifest (no `chain`) recomputes the raw rendition ids
|
|
41
|
+
(empty chain), which is correct for any non-preprocessed run.
|
|
42
|
+
"""
|
|
43
|
+
content_hash = str(source_entry.get("content_hash") or "")
|
|
44
|
+
if not content_hash:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"source {source_entry.get('source_path')!r} has no content_hash — "
|
|
47
|
+
"re-run transcription on the 0.3.0 manifest schema")
|
|
48
|
+
source_id = source_node_id(content_hash)
|
|
49
|
+
chain = list(source_entry.get("chain") or []) # [] = raw convert-only rendition
|
|
50
|
+
asegs: List[Dict[str, Any]] = []
|
|
51
|
+
for pseg in source_entry.get("segments") or []:
|
|
52
|
+
start, end = float(pseg.get("start", 0.0)), float(pseg.get("end", 0.0))
|
|
53
|
+
aseg_id = audio_segment_node_id(source_id, start, end)
|
|
54
|
+
rendition_id = audio_rendition_node_id(aseg_id, chain)
|
|
55
|
+
transcripts = {
|
|
56
|
+
t: transcript_node_id(rendition_id, t, str((capabilities_info.get(t) or {}).get("config_hash") or ""))
|
|
57
|
+
for t in (pseg.get("transcripts") or {})
|
|
58
|
+
}
|
|
59
|
+
asegs.append({"audio_segment": aseg_id, "rendition": rendition_id,
|
|
60
|
+
"start": start, "end": end,
|
|
61
|
+
"model_input_hash": str(pseg.get("model_input_hash") or ""),
|
|
62
|
+
"transcripts": transcripts})
|
|
63
|
+
return {"source": source_id, "chain": chain, "audio_segments": asegs}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_extension_payload(
|
|
67
|
+
source_entry: Dict[str, Any], # One source entry from the transcription manifest
|
|
68
|
+
capabilities_info: Dict[str, Dict[str, Any]], # The transcription manifest's capabilities block
|
|
69
|
+
vad_config_hash: str, # THIS run's VAD capability config hash (skeleton identity input)
|
|
70
|
+
text_from: str, # Authoritative transcriber (layer-0 text designation)
|
|
71
|
+
segments: List[DecompSegment], # Ordered aligned segments (per-transcriber variants attached)
|
|
72
|
+
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: # (nodes, edges, ids)
|
|
73
|
+
"""Build the fine-spine EXTENSION payload (pure; no capability calls).
|
|
74
|
+
|
|
75
|
+
Segment identity is audio-side (audio RENDITION, VAD config, chunk range) —
|
|
76
|
+
shared across transcribers by construction, distinct per rendition (vocals
|
|
77
|
+
isolation can yield different VAD chunking than raw). Each Segment carries
|
|
78
|
+
the audio `TimeSlice` ref into its rendition + one `CharSlice` ref per
|
|
79
|
+
transcriber variant; `text_from` is recorded per segment as provenance
|
|
80
|
+
(which Transcript the layer-0 text came from), never as global config. Edges
|
|
81
|
+
come from `grouped_spine_edges`: PART_OF to the OWNING AudioRendition,
|
|
82
|
+
STARTS_WITH per rendition, NEXT chained source-wide across coarse boundaries.
|
|
83
|
+
"""
|
|
84
|
+
roots = resolve_root_ids(source_entry, capabilities_info)
|
|
85
|
+
source_id = roots["source"]
|
|
86
|
+
asegs = roots["audio_segments"]
|
|
87
|
+
|
|
88
|
+
nodes: List[Dict[str, Any]] = []
|
|
89
|
+
seg_ids: List[str] = []
|
|
90
|
+
groups: Dict[int, List[str]] = {}
|
|
91
|
+
used_transcripts: set = set()
|
|
92
|
+
for seg in segments:
|
|
93
|
+
a = asegs[seg.pseg_index]
|
|
94
|
+
slices: List[TranscriptSliceRef] = []
|
|
95
|
+
for var in seg.variants:
|
|
96
|
+
tid = a["transcripts"].get(var.transcriber)
|
|
97
|
+
if tid is None or not var.text or var.start_char is None or var.end_char is None:
|
|
98
|
+
continue
|
|
99
|
+
slices.append(TranscriptSliceRef(transcript=tid, start_char=var.start_char,
|
|
100
|
+
end_char=var.end_char, text=var.text))
|
|
101
|
+
used_transcripts.add(tid)
|
|
102
|
+
node = SegmentNode(
|
|
103
|
+
rendition=a["rendition"], vad_config_hash=vad_config_hash,
|
|
104
|
+
chunk_start=seg.chunk_start, chunk_end=seg.chunk_end,
|
|
105
|
+
index=seg.index, start_time=seg.start_time, end_time=seg.end_time,
|
|
106
|
+
text=seg.text, audio_hash=a["model_input_hash"], source=source_id,
|
|
107
|
+
text_from=(a["transcripts"].get(text_from) if seg.text.strip() else None),
|
|
108
|
+
text_slices=slices,
|
|
109
|
+
)
|
|
110
|
+
nodes.append(node.to_graph_node())
|
|
111
|
+
seg_ids.append(node.id)
|
|
112
|
+
groups.setdefault(seg.pseg_index, []).append(node.id)
|
|
113
|
+
|
|
114
|
+
# The fine spine hangs under each segment's RENDITION (PART_OF rendition).
|
|
115
|
+
edges = grouped_spine_edges([(asegs[i]["rendition"], groups[i]) for i in sorted(groups)])
|
|
116
|
+
ids = {"source": source_id, "segments": seg_ids,
|
|
117
|
+
"audio_segments": [a["audio_segment"] for a in asegs],
|
|
118
|
+
"renditions": [a["rendition"] for a in asegs],
|
|
119
|
+
"transcripts_used": sorted(used_transcripts)}
|
|
120
|
+
return nodes, edges, ids
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class SourceVerification:
|
|
125
|
+
"""Skeptical-lens verification of one Source's fine-spine extension under a
|
|
126
|
+
specific rendition set, computed by querying the graph directly (never
|
|
127
|
+
trusting run state)."""
|
|
128
|
+
source_id: str # Verified Source node id
|
|
129
|
+
title: str # Source title (read back from the graph)
|
|
130
|
+
audio_segment_count: int # AudioSegment nodes found under the Source (coarse spine; rendition-independent)
|
|
131
|
+
rendition_count: int # AudioRendition nodes the fine spine was scoped to (the run's extended renditions)
|
|
132
|
+
segment_count: int # Fine Segment nodes found under those renditions
|
|
133
|
+
source_starts_with: bool # Exactly 1 STARTS_WITH Source -> first AudioSegment
|
|
134
|
+
aseg_next_complete: bool # Coarse NEXT count == audio_segment_count - 1
|
|
135
|
+
seg_next_complete: bool # Fine NEXT count == segment_count - 1 (source-wide chain)
|
|
136
|
+
part_of_complete: bool # Fine PART_OF count == segment_count (Segment -> rendition)
|
|
137
|
+
rendition_starts_with_complete: bool # STARTS_WITH from renditions == #renditions owning >=1 Segment
|
|
138
|
+
all_have_timing: bool # Every Segment has start_time + end_time
|
|
139
|
+
all_have_sources: bool # Every Segment has >=1 SourceRef (the audio ref at minimum)
|
|
140
|
+
source_locators: List[str] = field(default_factory=list) # Distinct provenance locator URIs
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def ok(self) -> bool: # True when every structural check passes
|
|
144
|
+
"""All structural checks pass."""
|
|
145
|
+
return (self.source_starts_with and self.aseg_next_complete
|
|
146
|
+
and self.seg_next_complete and self.part_of_complete
|
|
147
|
+
and self.rendition_starts_with_complete and self.all_have_timing
|
|
148
|
+
and self.all_have_sources)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def verify_source(
|
|
152
|
+
queue: JobQueue, # Started job queue
|
|
153
|
+
graph_id: str, # Graph-storage capability id
|
|
154
|
+
source_id: str, # Source node id to verify
|
|
155
|
+
rendition_ids: List[str], # The AudioRendition ids the run extended (the fine spine is scoped to these)
|
|
156
|
+
) -> Optional[SourceVerification]: # Result, or None if the Source is not found
|
|
157
|
+
"""Verify a Source's committed extension via server-side AGGREGATES (D13/D19).
|
|
158
|
+
|
|
159
|
+
Two-layer verification for the Source-rooted schema: the coarse spine
|
|
160
|
+
(Source → AudioSegments) is checked with id-list edge counts (rendition-
|
|
161
|
+
independent); the fine spine hangs under the run's AudioRendition set, so it
|
|
162
|
+
is scoped through the batched far-end constraint
|
|
163
|
+
`RelationPredicate("PART_OF", node_ids=rendition_ids)` (the C17 batch shape) —
|
|
164
|
+
never a whole-neighborhood materialization, and never mixing a sibling
|
|
165
|
+
rendition's spine (raw vs vocals coexist under the same AudioSegments). One
|
|
166
|
+
bounded projection pass (sources + rendition_id) yields missing-sources,
|
|
167
|
+
distinct locators, AND the per-rendition ownership set for the STARTS_WITH
|
|
168
|
+
check.
|
|
169
|
+
"""
|
|
170
|
+
src = await graph_task(queue, graph_id, "get_node", node_id=source_id)
|
|
171
|
+
if src is None:
|
|
172
|
+
return None
|
|
173
|
+
props = src.properties if isinstance(src, GraphNode) else ((src or {}).get("properties") or {})
|
|
174
|
+
|
|
175
|
+
async def _ecount(**kw):
|
|
176
|
+
q = EdgeQuery(count=True, **kw)
|
|
177
|
+
res = await graph_task(queue, graph_id, "query_edges", query=q.to_dict())
|
|
178
|
+
return int(res.count or 0)
|
|
179
|
+
|
|
180
|
+
# Coarse layer: ordered AudioSegment ids under the Source (rendition-independent).
|
|
181
|
+
aq = NodeQuery(label=TranscriptGraphLabels.AUDIO_SEGMENT,
|
|
182
|
+
related=RelationPredicate(SpineRelations.PART_OF, node_id=source_id),
|
|
183
|
+
order_by=OrderBy("index"), project=["index"])
|
|
184
|
+
ares = await graph_task(queue, graph_id, "query_nodes", query=aq.to_dict())
|
|
185
|
+
aseg_ids = [r["id"] for r in (ares.rows or [])]
|
|
186
|
+
n_asegs = len(aseg_ids)
|
|
187
|
+
rendition_ids = list(rendition_ids or [])
|
|
188
|
+
if not aseg_ids or not rendition_ids:
|
|
189
|
+
return SourceVerification(
|
|
190
|
+
source_id=source_id, title=props.get("title", "Untitled"),
|
|
191
|
+
audio_segment_count=n_asegs, rendition_count=len(rendition_ids),
|
|
192
|
+
segment_count=0, source_starts_with=False,
|
|
193
|
+
aseg_next_complete=False, seg_next_complete=False, part_of_complete=False,
|
|
194
|
+
rendition_starts_with_complete=False, all_have_timing=False, all_have_sources=False)
|
|
195
|
+
|
|
196
|
+
src_starts = await _ecount(relation_type=SpineRelations.STARTS_WITH, source_id=source_id)
|
|
197
|
+
aseg_next = await _ecount(relation_type=SpineRelations.NEXT, source_ids=aseg_ids)
|
|
198
|
+
|
|
199
|
+
# Fine layer, scoped via the batched far-end constraint onto THIS run's renditions.
|
|
200
|
+
part_of_rend = RelationPredicate(SpineRelations.PART_OF, node_ids=rendition_ids)
|
|
201
|
+
|
|
202
|
+
async def _ncount(**kw):
|
|
203
|
+
q = NodeQuery(label=TranscriptGraphLabels.SEGMENT, related=part_of_rend, count=True, **kw)
|
|
204
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
|
|
205
|
+
return int(res.count or 0)
|
|
206
|
+
|
|
207
|
+
n_segs = await _ncount()
|
|
208
|
+
seg_next = await _ecount(relation_type=SpineRelations.NEXT, source_related=part_of_rend)
|
|
209
|
+
seg_part_of = await _ecount(relation_type=SpineRelations.PART_OF, target_ids=rendition_ids)
|
|
210
|
+
rend_starts = await _ecount(relation_type=SpineRelations.STARTS_WITH, source_ids=rendition_ids)
|
|
211
|
+
missing_start = await _ncount(where=[PropertyPredicate("start_time", "is_null")])
|
|
212
|
+
missing_end = await _ncount(where=[PropertyPredicate("end_time", "is_null")])
|
|
213
|
+
|
|
214
|
+
# Bounded projection: sources + owning rendition in ONE pass.
|
|
215
|
+
sq = NodeQuery(label=TranscriptGraphLabels.SEGMENT, related=part_of_rend,
|
|
216
|
+
project=["sources", "rendition_id"])
|
|
217
|
+
res = await graph_task(queue, graph_id, "query_nodes", query=sq.to_dict())
|
|
218
|
+
missing_sources = 0
|
|
219
|
+
locators = set()
|
|
220
|
+
owning_renditions = set()
|
|
221
|
+
for r in (res.rows or []):
|
|
222
|
+
sources = r.get("sources") or []
|
|
223
|
+
if not sources:
|
|
224
|
+
missing_sources += 1
|
|
225
|
+
for s in sources:
|
|
226
|
+
ref = SourceRef.from_dict(s) if isinstance(s, dict) else s
|
|
227
|
+
locators.add(ref.locator.to_uri())
|
|
228
|
+
if r.get("rendition_id"):
|
|
229
|
+
owning_renditions.add(r["rendition_id"])
|
|
230
|
+
|
|
231
|
+
return SourceVerification(
|
|
232
|
+
source_id=source_id,
|
|
233
|
+
title=props.get("title", "Untitled"),
|
|
234
|
+
audio_segment_count=n_asegs,
|
|
235
|
+
rendition_count=len(rendition_ids),
|
|
236
|
+
segment_count=n_segs,
|
|
237
|
+
source_starts_with=src_starts == 1,
|
|
238
|
+
aseg_next_complete=aseg_next == max(0, n_asegs - 1),
|
|
239
|
+
seg_next_complete=seg_next == max(0, n_segs - 1),
|
|
240
|
+
part_of_complete=seg_part_of == n_segs,
|
|
241
|
+
rendition_starts_with_complete=rend_starts == len(owning_renditions),
|
|
242
|
+
all_have_timing=(missing_start == 0 and missing_end == 0),
|
|
243
|
+
all_have_sources=missing_sources == 0,
|
|
244
|
+
source_locators=sorted(locators),
|
|
245
|
+
)
|