cjm-transcript-correction-tui 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cjm_transcript_correction_tui/__init__.py +19 -0
- cjm_transcript_correction_tui/app.py +640 -0
- cjm_transcript_correction_tui/cli.py +68 -0
- cjm_transcript_correction_tui/spine.py +321 -0
- cjm_transcript_correction_tui-0.0.1.dist-info/METADATA +63 -0
- cjm_transcript_correction_tui-0.0.1.dist-info/RECORD +10 -0
- cjm_transcript_correction_tui-0.0.1.dist-info/WHEEL +5 -0
- cjm_transcript_correction_tui-0.0.1.dist-info/entry_points.txt +2 -0
- cjm_transcript_correction_tui-0.0.1.dist-info/licenses/LICENSE +201 -0
- cjm_transcript_correction_tui-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from cjm_substrate.core.workspace import resolve_workspace
|
|
5
|
+
|
|
6
|
+
from .app import CorrectionApp
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_parser() -> argparse.ArgumentParser: # Configured CLI parser
|
|
10
|
+
"""The TUI driver's argument surface (mirrors correction-core's run/review args)."""
|
|
11
|
+
p = argparse.ArgumentParser(
|
|
12
|
+
prog="cjm-transcript-correction-tui",
|
|
13
|
+
description="Keyboard-first correction loop over a transcription context graph "
|
|
14
|
+
"(document-order segment walk, VAD-chunk auto-play, fidelity edits).")
|
|
15
|
+
p.add_argument("--graph-db-path", required=True,
|
|
16
|
+
help="The shared transcription graph db (the committed spine)")
|
|
17
|
+
p.add_argument("--source", default=None,
|
|
18
|
+
help="Source node id or title substring (required when the graph "
|
|
19
|
+
"holds more than one Source)")
|
|
20
|
+
p.add_argument("--manifests-dir", default=None,
|
|
21
|
+
help="Capability manifests directory (default: the workspace's "
|
|
22
|
+
".cjm/manifests when one is active, else .cjm/manifests under the cwd)")
|
|
23
|
+
p.add_argument("--workspace", default=None,
|
|
24
|
+
help="Workspace root (5daadfc4; default: CJM_WORKSPACE env, else upward walk "
|
|
25
|
+
"from cwd). Supplies the manifests default and is exported so capability "
|
|
26
|
+
"workers resolve workspace-scoped paths; run/source DISCOVERY over the "
|
|
27
|
+
"workspace graph is the 2ce81638 follow-on")
|
|
28
|
+
p.add_argument("--rendition", default=None,
|
|
29
|
+
help="AudioRendition selector when a source has more than one "
|
|
30
|
+
"(\"raw\" or a preprocessing substring); default: auto-select")
|
|
31
|
+
p.add_argument("--actor", default="human",
|
|
32
|
+
help="Actor recorded on corrections + review markers")
|
|
33
|
+
p.add_argument("--no-autoplay", action="store_true",
|
|
34
|
+
help="Do not auto-play the focused segment's VAD chunk")
|
|
35
|
+
p.add_argument("--audio-device", default=None,
|
|
36
|
+
help="Output device index or name substring (default: the system "
|
|
37
|
+
"default sink — pipewire/pulse routing when available)")
|
|
38
|
+
p.add_argument("--no-resume", action="store_true",
|
|
39
|
+
help="Start at segment 0 instead of the source's last-focused segment")
|
|
40
|
+
p.add_argument("--shift-floor-ms", type=int, default=0,
|
|
41
|
+
help="Minimum milliseconds between held-key boundary shifts; 0 = ungoverned "
|
|
42
|
+
"(the async commit guard is the real governor — a 1ms floor read as "
|
|
43
|
+
"residual keystroke latency in the 2026-07-14 drive). "
|
|
44
|
+
"Measure key rates with tests_manual/keyrate_probe.py")
|
|
45
|
+
return p
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main() -> int: # Console-script entry point
|
|
49
|
+
"""Parse args, run the correction loop (the app owns the event loop + teardown)."""
|
|
50
|
+
args = build_parser().parse_args()
|
|
51
|
+
# 5daadfc4 workspace: resolve before anything reads paths; export so
|
|
52
|
+
# capability workers (ffmpeg etc.) are workspace-scoped.
|
|
53
|
+
ws = resolve_workspace(explicit=args.workspace)
|
|
54
|
+
if ws is not None:
|
|
55
|
+
os.environ["CJM_WORKSPACE"] = str(ws.root)
|
|
56
|
+
if args.manifests_dir is None:
|
|
57
|
+
args.manifests_dir = (str(ws.substrate_data_dir / "manifests")
|
|
58
|
+
if ws is not None else ".cjm/manifests")
|
|
59
|
+
device = args.audio_device
|
|
60
|
+
if device is not None and device.isdigit():
|
|
61
|
+
device = int(device)
|
|
62
|
+
app = CorrectionApp(args.graph_db_path, source=args.source,
|
|
63
|
+
manifests_dir=args.manifests_dir, rendition=args.rendition,
|
|
64
|
+
actor=args.actor, autoplay=not args.no_autoplay,
|
|
65
|
+
audio_device=device, resume=not args.no_resume,
|
|
66
|
+
shift_floor_s=args.shift_floor_ms / 1000.0)
|
|
67
|
+
app.run()
|
|
68
|
+
return 0
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
from bisect import bisect_right
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from cjm_context_graph_layer.grammar import OverlayRelations, SpineRelations
|
|
7
|
+
from cjm_context_graph_layer.ops import graph_task
|
|
8
|
+
from cjm_context_graph_primitives.query import NodeQuery, OrderBy, RelationPredicate
|
|
9
|
+
from cjm_substrate.core.manager import CapabilityManager
|
|
10
|
+
from cjm_substrate.core.queue import JobQueue
|
|
11
|
+
from cjm_transcript_correction_core.cli import load_capabilities
|
|
12
|
+
from cjm_transcript_correction_core.graph import (active_corrections, load_source_corrections,
|
|
13
|
+
load_source_segments, mark_anchor_segments,
|
|
14
|
+
open_marks, project_effective_spine,
|
|
15
|
+
resolve_source_renditions)
|
|
16
|
+
from cjm_transcript_correction_core.models import SpineSegment
|
|
17
|
+
from cjm_transcript_graph_schema.schema import TranscriptGraphLabels
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ChunkRef:
|
|
22
|
+
"""Where one Segment's VAD-chunk audio lives: the model-input WAV + the chunk-local span.
|
|
23
|
+
|
|
24
|
+
The correction loop plays from the model-input rendition (what the model heard),
|
|
25
|
+
so the span is expressed LOCAL to that AudioSegment's WAV — Segment times are
|
|
26
|
+
source-coordinate on the graph; the join subtracts the owning AudioSegment's
|
|
27
|
+
start."""
|
|
28
|
+
wav_path: str # The AudioSegment's model-input WAV (16 kHz mono)
|
|
29
|
+
start_s: float # Chunk start, seconds, local to `wav_path`
|
|
30
|
+
end_s: float # Chunk end, seconds, local to `wav_path`
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SpineView:
|
|
34
|
+
"""One Source's effective correction spine, cursor-windowed for the TUI.
|
|
35
|
+
|
|
36
|
+
The driver's single seat at the graph: bootstraps the substrate capability stack
|
|
37
|
+
(graph worker pointed at the shared transcription graph), resolves the rendition
|
|
38
|
+
chain, loads the fine Segment spine, applies the effective projection (layer-0 +
|
|
39
|
+
active corrections — the SAME read every downstream consumer gets), and joins
|
|
40
|
+
each Segment to its model-input WAV chunk. The TUI renders WINDOWS of this view
|
|
41
|
+
(`window(cursor, count)` — scrolling moves the CURSOR, slots re-bind around it;
|
|
42
|
+
there is no viewport state) and slices audio via `chunk(i)`. Open MARKS load
|
|
43
|
+
with the corrections (⚑ bookkeeping only — they never touch the projection).
|
|
44
|
+
Reads come through correction-core's operation vocabulary; writes too — the
|
|
45
|
+
TUI never touches the graph directly."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, manager: CapabilityManager, queue: JobQueue, graph_id: str,
|
|
48
|
+
source_id: str, source_title: str):
|
|
49
|
+
self._manager = manager
|
|
50
|
+
self.queue = queue
|
|
51
|
+
self.graph_id = graph_id
|
|
52
|
+
self.source_id = source_id
|
|
53
|
+
self.source_title = source_title
|
|
54
|
+
self.segments: List[SpineSegment] = [] # Full-skeleton effective spine (text edits applied, prunes marked)
|
|
55
|
+
self.pruned_ids: set = set() # Segment ids a prune correction targets (card marks)
|
|
56
|
+
self._prune_corrections: List[dict] = [] # Active prune Corrections (unprune anchors)
|
|
57
|
+
self._open_marks: List[dict] = [] # OPEN mark Corrections (routed attention)
|
|
58
|
+
self.marked_ids: set = set() # Segment ids an open mark anchors (⚑ glyphs)
|
|
59
|
+
self.seen_mark_classes: List[str] = [] # DISTINCT classes journaled on this source (open or discharged)
|
|
60
|
+
self._aseg_starts: List[float] = [] # AudioSegment starts (sorted, for bisect)
|
|
61
|
+
self._aseg_audio: List[Optional[ChunkRef]] = [] # Parallel: (wav, aseg-start) join stubs
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
async def open(cls, graph_db_path: str, # The shared transcription graph db
|
|
65
|
+
*, source: Optional[str] = None, # Source node id OR a title substring
|
|
66
|
+
manifests_dir: str = ".cjm/manifests", # Capability manifests directory
|
|
67
|
+
graph_capability: str = "cjm-capability-graph-sqlite",
|
|
68
|
+
rendition: Optional[str] = None, # Rendition selector (None = auto)
|
|
69
|
+
) -> "SpineView":
|
|
70
|
+
"""Bootstrap the capability stack and load one Source's effective spine."""
|
|
71
|
+
manager = CapabilityManager(search_paths=[Path(manifests_dir)])
|
|
72
|
+
load_capabilities(manager, [graph_capability],
|
|
73
|
+
configs={graph_capability: {"db_path": str(graph_db_path)}})
|
|
74
|
+
queue = JobQueue(deps=manager)
|
|
75
|
+
await queue.start()
|
|
76
|
+
try:
|
|
77
|
+
sq = NodeQuery(label="Source", project=["title"])
|
|
78
|
+
res = await graph_task(queue, graph_capability, "query_nodes", query=sq.to_dict())
|
|
79
|
+
sources = [(r["id"], str(r.get("title") or "")) for r in (res.rows or [])]
|
|
80
|
+
if source:
|
|
81
|
+
picked = [(i, t) for i, t in sources
|
|
82
|
+
if i == source or source.lower() in t.lower()]
|
|
83
|
+
else:
|
|
84
|
+
picked = sources
|
|
85
|
+
if len(picked) != 1:
|
|
86
|
+
titles = "; ".join(t for _, t in sources)
|
|
87
|
+
raise ValueError(f"need exactly one Source (matched {len(picked)}) — "
|
|
88
|
+
f"pass `source=` an id or title substring; available: {titles}")
|
|
89
|
+
view = cls(manager, queue, graph_capability, picked[0][0], picked[0][1])
|
|
90
|
+
await view._load(rendition)
|
|
91
|
+
return view
|
|
92
|
+
except BaseException:
|
|
93
|
+
await queue.stop()
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
async def _load(self, rendition: Optional[str]) -> None:
|
|
97
|
+
"""Load spine + corrections + the audio join (one Source, one rendition chain)."""
|
|
98
|
+
segments = await load_source_segments(self.queue, self.graph_id, self.source_id,
|
|
99
|
+
rendition_selector=rendition)
|
|
100
|
+
corrections, superseded = await load_source_corrections(
|
|
101
|
+
self.queue, self.graph_id, self.source_id)
|
|
102
|
+
active = active_corrections(corrections, superseded)
|
|
103
|
+
# Open marks paint ⚑ in the walk; they NEVER touch the projection
|
|
104
|
+
# (corrections_to_edits has no arm for correction_type "mark" — DEC 2a231843).
|
|
105
|
+
self._open_marks = open_marks(corrections, superseded)
|
|
106
|
+
self._recompute_marked_ids()
|
|
107
|
+
# The correction surface walks the FULL VAD skeleton (the 1:1 invariant):
|
|
108
|
+
# prune corrections are NOT applied to this view — an "empty" chunk may hold
|
|
109
|
+
# speech that FA starved (the falsified D14 premise), and an empty chunk is
|
|
110
|
+
# exactly where a boundary-shift pulls mis-assigned text back. Pruned ids
|
|
111
|
+
# surface as card marks instead of disappearing from the walk.
|
|
112
|
+
self._prune_corrections = [
|
|
113
|
+
c for c in active
|
|
114
|
+
if c.get("correction_type") == "grouping"
|
|
115
|
+
and (c.get("payload") or {}).get("operation") == "prune_empty"]
|
|
116
|
+
self.pruned_ids = {
|
|
117
|
+
sid for c in self._prune_corrections
|
|
118
|
+
for sid in (c.get("payload") or {}).get("pruned_segment_ids") or []}
|
|
119
|
+
# Prunes are the ONLY corrections withheld from this view (their
|
|
120
|
+
# positions stay walkable, marked); boundary shifts and text edits
|
|
121
|
+
# APPLY, review verdicts map to no edit (58b2e0a0 residual fix).
|
|
122
|
+
prune_ids = {c.get("id") for c in self._prune_corrections}
|
|
123
|
+
projected = [c for c in active if c.get("id") not in prune_ids]
|
|
124
|
+
self.segments = project_effective_spine(segments, projected)
|
|
125
|
+
rend_ids = set(await resolve_source_renditions(
|
|
126
|
+
self.queue, self.graph_id, self.source_id, rendition))
|
|
127
|
+
aq = NodeQuery(label=TranscriptGraphLabels.AUDIO_SEGMENT,
|
|
128
|
+
related=RelationPredicate(SpineRelations.PART_OF, node_id=self.source_id),
|
|
129
|
+
order_by=OrderBy(prop="start"), project=["start", "end"])
|
|
130
|
+
ares = await graph_task(self.queue, self.graph_id, "query_nodes", query=aq.to_dict())
|
|
131
|
+
asegs = [(r["id"], float(r.get("start") or 0.0)) for r in (ares.rows or [])]
|
|
132
|
+
rq = NodeQuery(label=TranscriptGraphLabels.AUDIO_RENDITION,
|
|
133
|
+
related=RelationPredicate(OverlayRelations.DERIVED_FROM,
|
|
134
|
+
node_ids=[a[0] for a in asegs]),
|
|
135
|
+
project=["model_input_path", "audio_segment_id"])
|
|
136
|
+
rres = await graph_task(self.queue, self.graph_id, "query_nodes", query=rq.to_dict())
|
|
137
|
+
wav_by_aseg: Dict[str, str] = {
|
|
138
|
+
str(r.get("audio_segment_id")): str(r.get("model_input_path") or "")
|
|
139
|
+
for r in (rres.rows or []) if r["id"] in rend_ids}
|
|
140
|
+
self._aseg_starts = [start for _, start in asegs]
|
|
141
|
+
self._aseg_audio = [
|
|
142
|
+
ChunkRef(wav_by_aseg[aid], start, 0.0) if aid in wav_by_aseg else None
|
|
143
|
+
for aid, start in asegs]
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def size(self) -> int: # Total segments in the effective spine
|
|
147
|
+
return len(self.segments)
|
|
148
|
+
|
|
149
|
+
def window(self, cursor: int, count: int) -> List[SpineSegment]:
|
|
150
|
+
"""The slot window around the cursor — clamped, cursor-parameterized, stateless."""
|
|
151
|
+
if not self.segments or count <= 0:
|
|
152
|
+
return []
|
|
153
|
+
half = count // 2
|
|
154
|
+
start = max(0, min(max(0, cursor - half), len(self.segments) - count))
|
|
155
|
+
return self.segments[start:start + count]
|
|
156
|
+
|
|
157
|
+
def aseg_index(self, index: int) -> Optional[int]:
|
|
158
|
+
"""Which coarse AudioSegment (by position) a segment sits in — seam rendering."""
|
|
159
|
+
if not (0 <= index < len(self.segments)) or not self._aseg_starts:
|
|
160
|
+
return None
|
|
161
|
+
seg = self.segments[index]
|
|
162
|
+
if seg.start_time is None:
|
|
163
|
+
return None
|
|
164
|
+
return max(0, bisect_right(self._aseg_starts, float(seg.start_time)) - 1)
|
|
165
|
+
|
|
166
|
+
def chunk(self, index: int) -> Optional[ChunkRef]:
|
|
167
|
+
"""The Segment's VAD-chunk audio ref (model-input WAV + chunk-local span), or None."""
|
|
168
|
+
if not (0 <= index < len(self.segments)) or not self._aseg_starts:
|
|
169
|
+
return None
|
|
170
|
+
seg = self.segments[index]
|
|
171
|
+
if seg.start_time is None or seg.end_time is None:
|
|
172
|
+
return None
|
|
173
|
+
i = max(0, bisect_right(self._aseg_starts, float(seg.start_time)) - 1)
|
|
174
|
+
stub = self._aseg_audio[i]
|
|
175
|
+
if stub is None or not stub.wav_path:
|
|
176
|
+
return None
|
|
177
|
+
return ChunkRef(stub.wav_path,
|
|
178
|
+
float(seg.start_time) - stub.start_s,
|
|
179
|
+
float(seg.end_time) - stub.start_s)
|
|
180
|
+
|
|
181
|
+
def prune_correction_for(self, segment_id: str) -> Optional[dict]:
|
|
182
|
+
"""The active prune Correction covering a segment (the unprune anchor), or None."""
|
|
183
|
+
for c in self._prune_corrections:
|
|
184
|
+
if segment_id in ((c.get("payload") or {}).get("pruned_segment_ids") or []):
|
|
185
|
+
return c
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def unprune_local(self, prior_id: str, amended: dict) -> None:
|
|
189
|
+
"""Local echo of a committed prune amendment (amended supersedes prior_id)."""
|
|
190
|
+
self._prune_corrections = [amended if c.get("id") == prior_id else c
|
|
191
|
+
for c in self._prune_corrections]
|
|
192
|
+
self.pruned_ids = {
|
|
193
|
+
sid for c in self._prune_corrections
|
|
194
|
+
for sid in (c.get("payload") or {}).get("pruned_segment_ids") or []}
|
|
195
|
+
|
|
196
|
+
def _recompute_marked_ids(self) -> None:
|
|
197
|
+
"""Re-derive the ⚑ id set + observed class list from the OPEN marks
|
|
198
|
+
(load + local echoes). A class leaves the picker menu when its last
|
|
199
|
+
open mark on this source is discharged — junk classes clean up via
|
|
200
|
+
dismissal; proven classes persist by PROMOTION into the recommended
|
|
201
|
+
slate, never by haunting the menu from discharged marks."""
|
|
202
|
+
self.marked_ids = set()
|
|
203
|
+
for m in self._open_marks:
|
|
204
|
+
try:
|
|
205
|
+
self.marked_ids.update(mark_anchor_segments(
|
|
206
|
+
(m.get("payload") or {}).get("anchor") or {}))
|
|
207
|
+
except ValueError:
|
|
208
|
+
continue # malformed historical mark: skip its glyph, never break the walk
|
|
209
|
+
self.seen_mark_classes = sorted({
|
|
210
|
+
str((m.get("payload") or {}).get("mark_class"))
|
|
211
|
+
for m in self._open_marks
|
|
212
|
+
if (m.get("payload") or {}).get("mark_class")})
|
|
213
|
+
|
|
214
|
+
def marks_for(self, segment_id: str) -> List[dict]:
|
|
215
|
+
"""The open marks anchored to a segment (oldest first) — dismissal targets."""
|
|
216
|
+
out = []
|
|
217
|
+
for m in self._open_marks:
|
|
218
|
+
try:
|
|
219
|
+
ids = mark_anchor_segments((m.get("payload") or {}).get("anchor") or {})
|
|
220
|
+
except ValueError:
|
|
221
|
+
continue
|
|
222
|
+
if segment_id in ids:
|
|
223
|
+
out.append(m)
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
def add_mark_local(self, mark: dict) -> None:
|
|
227
|
+
"""Local echo of a committed mark (the ⚑ paints without a reload)."""
|
|
228
|
+
self._open_marks.append(mark)
|
|
229
|
+
self._recompute_marked_ids()
|
|
230
|
+
|
|
231
|
+
def dismiss_mark_local(self, mark_id: str) -> None:
|
|
232
|
+
"""Local echo of a mark dismissal."""
|
|
233
|
+
self._open_marks = [m for m in self._open_marks if m.get("id") != mark_id]
|
|
234
|
+
self._recompute_marked_ids()
|
|
235
|
+
|
|
236
|
+
async def close(self) -> None:
|
|
237
|
+
"""Tear down the queue + capability stack (app exit)."""
|
|
238
|
+
await self.queue.stop()
|
|
239
|
+
try:
|
|
240
|
+
self._manager.unload_capability(self.graph_id)
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def plan_boundary_shift(
|
|
246
|
+
left_text: str, # The cursor segment's current effective text
|
|
247
|
+
right_text: str, # The next segment's current effective text
|
|
248
|
+
direction: str, # "push" (last word of left -> right) | "pull" (first word of right -> left)
|
|
249
|
+
) -> Optional[Tuple[str, str, str]]: # (moved word, new left text, new right text); None = nothing to move
|
|
250
|
+
"""Plan a ONE-WORD boundary shift (the [ / ] gesture unit).
|
|
251
|
+
|
|
252
|
+
Mirrors the layer's junction-normalizing semantics (DEC f83c6931) for the
|
|
253
|
+
local echo: single-space joins, vacated boundary whitespace collapses.
|
|
254
|
+
Repeat presses chain one-word corrections; the projection applies them in
|
|
255
|
+
created_at order over the evolving text, so the chain composes exactly.
|
|
256
|
+
"""
|
|
257
|
+
if direction == "push":
|
|
258
|
+
words = left_text.split()
|
|
259
|
+
if not words:
|
|
260
|
+
return None
|
|
261
|
+
moved = words[-1]
|
|
262
|
+
base = left_text.rstrip()
|
|
263
|
+
new_left = base[: len(base) - len(moved)].rstrip()
|
|
264
|
+
rtext = right_text.lstrip()
|
|
265
|
+
new_right = f"{moved} {rtext}" if rtext else moved
|
|
266
|
+
else:
|
|
267
|
+
words = right_text.split()
|
|
268
|
+
if not words:
|
|
269
|
+
return None
|
|
270
|
+
moved = words[0]
|
|
271
|
+
base = right_text.lstrip()
|
|
272
|
+
new_right = base[len(moved):].lstrip()
|
|
273
|
+
ltext = left_text.rstrip()
|
|
274
|
+
new_left = f"{ltext} {moved}" if ltext else moved
|
|
275
|
+
return moved, new_left, new_right
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def parse_mark_input(
|
|
279
|
+
raw: str, # The mark-editor submission: `class ["snippet"] [note...]`
|
|
280
|
+
segment_text: str, # The focused segment's current effective text (span lookup)
|
|
281
|
+
) -> Optional[Tuple[str, Optional[Tuple[int, int, str]], Optional[str]]]: # (class, span, note); None = empty input
|
|
282
|
+
"""Parse the M-editor mark grammar (pure; the DEC 2a231843 TUI gesture).
|
|
283
|
+
|
|
284
|
+
First token = the mark class (open vocabulary). An optional "double-quoted"
|
|
285
|
+
snippet that occurs in the segment text becomes a SPAN anchor (first
|
|
286
|
+
occurrence; offsets + verbatim snapshot). Everything else is the note.
|
|
287
|
+
A quoted snippet NOT found in the text stays part of the note — the mark
|
|
288
|
+
degrades to segment scope rather than recording a false span.
|
|
289
|
+
"""
|
|
290
|
+
text = (raw or "").strip()
|
|
291
|
+
if not text:
|
|
292
|
+
return None
|
|
293
|
+
head, _, rest = text.partition(" ")
|
|
294
|
+
rest = rest.strip()
|
|
295
|
+
span = None
|
|
296
|
+
if rest.startswith('"') and '"' in rest[1:]:
|
|
297
|
+
snippet, _, tail = rest[1:].partition('"')
|
|
298
|
+
at = segment_text.find(snippet) if snippet else -1
|
|
299
|
+
if at != -1:
|
|
300
|
+
span = (at, at + len(snippet), snippet)
|
|
301
|
+
rest = tail.strip()
|
|
302
|
+
return head, span, (rest or None)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def resolve_mark_class_token(
|
|
306
|
+
raw: str, # The mark-editor submission (possibly `N ...`)
|
|
307
|
+
menu: List[str], # Selectable classes (recommended slate + observed)
|
|
308
|
+
) -> Tuple[str, Optional[str]]: # (possibly-rewritten submission, error message or None)
|
|
309
|
+
"""Resolve a leading digit token to its menu class (the M picker; pure).
|
|
310
|
+
|
|
311
|
+
`2 "snippet" note` becomes `<menu[1]> "snippet" note` — everything after
|
|
312
|
+
the digit is preserved VERBATIM (a snippet's inner spacing must survive).
|
|
313
|
+
Out-of-range numbers return an error instead of minting a numeric class.
|
|
314
|
+
"""
|
|
315
|
+
head, _, rest = (raw or "").strip().partition(" ")
|
|
316
|
+
if not head.isdigit():
|
|
317
|
+
return raw, None
|
|
318
|
+
n = int(head)
|
|
319
|
+
if not (1 <= n <= len(menu)):
|
|
320
|
+
return raw, f"no class #{n} (menu is 1-{len(menu)})"
|
|
321
|
+
return f"{menu[n - 1]} {rest}".strip(), None
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cjm-transcript-correction-tui
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Transcript-correction TUI driver: keyboard-first terminal review surface (cursor-windowed segment walk, VAD-chunk audio auto-play, fidelity + boundary-shift corrections, agents-propose/humans-confirm triage) over cjm-transcript-correction-core's operation vocabulary. The first born-on-graph library of the self-hosting graph arc.
|
|
5
|
+
Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Repository, https://github.com/cj-mills/cjm-transcript-correction-tui
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: cjm-transcript-correction-core>=0.0.4
|
|
14
|
+
Requires-Dist: cjm-context-graph-layer>=0.0.11
|
|
15
|
+
Requires-Dist: cjm-substrate-tui-kit>=0.0.1
|
|
16
|
+
Requires-Dist: textual
|
|
17
|
+
Requires-Dist: numpy
|
|
18
|
+
Requires-Dist: sounddevice
|
|
19
|
+
Requires-Dist: soundfile
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# cjm-transcript-correction-tui
|
|
23
|
+
|
|
24
|
+
<!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
|
|
25
|
+
|
|
26
|
+
The minimal presentation driver for cjm-transcript-correction-core: a document-order segment walk over the shared transcript graph with chunk audio playback, held-key boundary-shift gestures, inline text correction, and review markers — the human pass-1/pass-2 correction loop's UI. Kept a separate library from the headless core per the non-minimal-drivers-separate-libs rule; every correction write appends through to the workflow's sidecar journal.
|
|
27
|
+
|
|
28
|
+
## Modules
|
|
29
|
+
|
|
30
|
+
- **`cjm_transcript_correction_tui.__init__`** — Keyboard-first TUI driver for the transcript-correction workflow — a presentation
|
|
31
|
+
- **`cjm_transcript_correction_tui.app`**
|
|
32
|
+
- **`cjm_transcript_correction_tui.audio`**
|
|
33
|
+
- **`cjm_transcript_correction_tui.cli`**
|
|
34
|
+
- **`cjm_transcript_correction_tui.spine`**
|
|
35
|
+
|
|
36
|
+
## API
|
|
37
|
+
|
|
38
|
+
### `cjm_transcript_correction_tui.app`
|
|
39
|
+
|
|
40
|
+
- `CorrectionApp` _class_ — The correction loop, v0 thinnest slice: document-order segment walk with
|
|
41
|
+
- `load_tui_state` _function_ — Read the per-graph TUI sidecar state (last-focused positions).
|
|
42
|
+
- `save_tui_state` _function_ — Merge one source's last-focused position into the sidecar state file.
|
|
43
|
+
|
|
44
|
+
### `cjm_transcript_correction_tui.audio`
|
|
45
|
+
|
|
46
|
+
- `ChunkPlayer` _class_ — Persistent-output-stream VAD-chunk player — the focus-walk auto-play engine.
|
|
47
|
+
- `load_chunk` _function_ — Read one VAD chunk's samples from the model-input WAV — frame-sliced, sample-accurate.
|
|
48
|
+
- `stretch` _function_ — Pitch-preserving time-stretch (WSOLA, numpy-only) — the playback-speed engine.
|
|
49
|
+
|
|
50
|
+
### `cjm_transcript_correction_tui.cli`
|
|
51
|
+
|
|
52
|
+
- `build_parser` _function_ — The TUI driver's argument surface (mirrors correction-core's run/review args).
|
|
53
|
+
- `main` _function_ — Parse args, run the correction loop (the app owns the event loop + teardown).
|
|
54
|
+
|
|
55
|
+
### `cjm_transcript_correction_tui.spine`
|
|
56
|
+
|
|
57
|
+
- `ChunkRef` _class_ — Where one Segment's VAD-chunk audio lives: the model-input WAV + the chunk-local span.
|
|
58
|
+
- `SpineView` _class_ — One Source's effective correction spine, cursor-windowed for the TUI.
|
|
59
|
+
- `plan_boundary_shift` _function_ — Plan a ONE-WORD boundary shift (the [ / ] gesture unit).
|
|
60
|
+
|
|
61
|
+
## Dependencies
|
|
62
|
+
|
|
63
|
+
**Depends on:** `cjm-context-graph-layer`, `cjm-transcript-correction-core`, `numpy`, `sounddevice`, `soundfile`, `textual`
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cjm_transcript_correction_tui/__init__.py,sha256=pZkWcO1mKbzxwTLMYicmlI6EX6fJ3iPXnvwmTjznHPM,1117
|
|
2
|
+
cjm_transcript_correction_tui/app.py,sha256=jeOvYpFJcOjkot7FE8P3wg4yhVGX4OBafdETf_9znTg,31340
|
|
3
|
+
cjm_transcript_correction_tui/cli.py,sha256=GD6JiTK8BbEpr44ygkw0OgTZ3FptgzPiiTiF-rr75wI,3882
|
|
4
|
+
cjm_transcript_correction_tui/spine.py,sha256=OR0jBwLuWZkCJhipqJOd8mZI2_xQ3bkUCqKrqQjjKVA,16743
|
|
5
|
+
cjm_transcript_correction_tui-0.0.1.dist-info/licenses/LICENSE,sha256=xV8xoN4VOL0uw9X8RSs2IMuD_Ss_a9yAbtGNeBWZwnw,11337
|
|
6
|
+
cjm_transcript_correction_tui-0.0.1.dist-info/METADATA,sha256=gBrE1DfBfz4UM8zaO7ZYshax194tS3HuR7683xjrqeQ,3488
|
|
7
|
+
cjm_transcript_correction_tui-0.0.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
cjm_transcript_correction_tui-0.0.1.dist-info/entry_points.txt,sha256=JA2wc74AOwVLsKg5TQn5rJZs1vl6TcHTQX17pWv_YMQ,89
|
|
9
|
+
cjm_transcript_correction_tui-0.0.1.dist-info/top_level.txt,sha256=oKp9N3nvV0nW3b7teWDQmMQze7u2c67Nn_KEqavzpnU,30
|
|
10
|
+
cjm_transcript_correction_tui-0.0.1.dist-info/RECORD,,
|