cjm-transcript-decomp-tui 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: cjm-transcript-decomp-tui
3
+ Version: 0.0.1
4
+ Summary: Decomp-batch TUI driver: browse transcription-core run manifests, queue an ordered multi-run batch with per-run authoritative-text picks, then hand off to cjm-transcript-decomp-core's headless batch runner (one capability stack for the whole batch). Born on-graph.
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-decomp-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
+ Requires-Dist: cjm-substrate-tui-kit>=0.0.1
13
+ Requires-Dist: cjm-transcript-decomp-core>=0.0.4
14
+ Requires-Dist: textual
15
+
16
+ # cjm-transcript-decomp-tui
17
+
18
+ <!-- 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) -->
19
+
20
+ Decomp-batch TUI driver for the transcript-decomposition workflow: browse the transcription core's run manifests, queue an ordered multi-run batch with per-run authoritative-text (text-from) picks, preview how the batch folds into headless invocations, then hand off to cjm-transcript-decomp-core's batch runner — one loaded capability stack per text-from group, every run byte-identical to a hand-launched one. Includes a read-only decomp-results view over the decomp core's own manifests.
21
+
22
+ ## Modules
23
+
24
+ - **`cjm_transcript_decomp_tui.__init__`**
25
+ - **`cjm_transcript_decomp_tui.app`**
26
+ - **`cjm_transcript_decomp_tui.cli`**
27
+ - **`cjm_transcript_decomp_tui.discovery`**
28
+ - **`cjm_transcript_decomp_tui.runs`**
29
+ - **`cjm_transcript_decomp_tui.state`**
30
+
31
+ ## API
32
+
33
+ ### `cjm_transcript_decomp_tui.app`
34
+
35
+ - `DecompApp` _class_ — Decomp-batch setup, v0 thinnest slice: one selection stage over the
36
+
37
+ ### `cjm_transcript_decomp_tui.cli`
38
+
39
+ - `batch_argv` _function_ — Render one text-from group as headless decomp-core argv.
40
+ - `build_parser` _function_ — The TUI driver's argument surface (batch-setup options + core passthrough).
41
+ - `main` _function_ — Resolve settings (flags > persisted state > manifest discovery), run the
42
+
43
+ ### `cjm_transcript_decomp_tui.discovery`
44
+
45
+ - `discover_capability` _function_ — Pick a DEFAULT capability for a role by surface match.
46
+ - `manifests_with_method` _function_ — Enumerate installed capabilities whose structural surface lists `method`.
47
+
48
+ ### `cjm_transcript_decomp_tui.runs`
49
+
50
+ - `DecompIndex` _class_ — Decomp-core run manifests read back: coverage chips for the batch stage
51
+ - `SourceRunIndex` _class_ — Transcription-core run manifests — the decomp workflow's SOURCES — plus
52
+ - `group_by_text_from` _function_ — Fold an ordered batch selection into headless hand-off groups.
53
+
54
+ ### `cjm_transcript_decomp_tui.state`
55
+
56
+ - `load_state` _function_ — Read this project's persisted TUI state.
57
+ - `save_state` _function_ — Merge updates into the persisted state and write it back (best-effort:
58
+ - `state_path` _function_ — Where this project's TUI state lives.
59
+
60
+ ## Dependencies
61
+
62
+ **Depends on:** `cjm-substrate-tui-kit`, `cjm-transcript-decomp-core`, `textual`
@@ -0,0 +1,47 @@
1
+ # cjm-transcript-decomp-tui
2
+
3
+ <!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
4
+
5
+ Decomp-batch TUI driver for the transcript-decomposition workflow: browse the transcription core's run manifests, queue an ordered multi-run batch with per-run authoritative-text (text-from) picks, preview how the batch folds into headless invocations, then hand off to cjm-transcript-decomp-core's batch runner — one loaded capability stack per text-from group, every run byte-identical to a hand-launched one. Includes a read-only decomp-results view over the decomp core's own manifests.
6
+
7
+ ## Modules
8
+
9
+ - **`cjm_transcript_decomp_tui.__init__`**
10
+ - **`cjm_transcript_decomp_tui.app`**
11
+ - **`cjm_transcript_decomp_tui.cli`**
12
+ - **`cjm_transcript_decomp_tui.discovery`**
13
+ - **`cjm_transcript_decomp_tui.runs`**
14
+ - **`cjm_transcript_decomp_tui.state`**
15
+
16
+ ## API
17
+
18
+ ### `cjm_transcript_decomp_tui.app`
19
+
20
+ - `DecompApp` _class_ — Decomp-batch setup, v0 thinnest slice: one selection stage over the
21
+
22
+ ### `cjm_transcript_decomp_tui.cli`
23
+
24
+ - `batch_argv` _function_ — Render one text-from group as headless decomp-core argv.
25
+ - `build_parser` _function_ — The TUI driver's argument surface (batch-setup options + core passthrough).
26
+ - `main` _function_ — Resolve settings (flags > persisted state > manifest discovery), run the
27
+
28
+ ### `cjm_transcript_decomp_tui.discovery`
29
+
30
+ - `discover_capability` _function_ — Pick a DEFAULT capability for a role by surface match.
31
+ - `manifests_with_method` _function_ — Enumerate installed capabilities whose structural surface lists `method`.
32
+
33
+ ### `cjm_transcript_decomp_tui.runs`
34
+
35
+ - `DecompIndex` _class_ — Decomp-core run manifests read back: coverage chips for the batch stage
36
+ - `SourceRunIndex` _class_ — Transcription-core run manifests — the decomp workflow's SOURCES — plus
37
+ - `group_by_text_from` _function_ — Fold an ordered batch selection into headless hand-off groups.
38
+
39
+ ### `cjm_transcript_decomp_tui.state`
40
+
41
+ - `load_state` _function_ — Read this project's persisted TUI state.
42
+ - `save_state` _function_ — Merge updates into the persisted state and write it back (best-effort:
43
+ - `state_path` _function_ — Where this project's TUI state lives.
44
+
45
+ ## Dependencies
46
+
47
+ **Depends on:** `cjm-substrate-tui-kit`, `cjm-transcript-decomp-core`, `textual`
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,487 @@
1
+ """The decomp-batch TUI: pick transcription runs into an ordered batch, then a
2
+ headless hand-off to the decomp core's batch runner (work item 0ff6bf0f).
3
+
4
+ v0 is deliberately the analogy's THIN slice: the felt pain was queueing — the
5
+ decomp CLI took one transcription-run manifest per invocation — so the app is
6
+ one selection stage over the run-manifest corpus plus a read-only results
7
+ view; which comparison/inspection views decomp actually needs stays
8
+ demand-driven from real decomp sessions, not mirrored speculatively from the
9
+ transcription TUI. Presentation lessons carried: spans-only Rich styling (base
10
+ styles bleed), no markup parsing of content strings (bare [/] would
11
+ MarkupError), AUTO_FOCUS None so bindings own the keys, one-line listing rows,
12
+ coalesced repaints (kit RepaintThrottle)."""
13
+
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+
18
+ from cjm_substrate_tui_kit.repaint import RepaintThrottle
19
+ from cjm_substrate_tui_kit.viewport import tail, visible_slice
20
+ from rich.text import Text
21
+ from textual.app import App, ComposeResult
22
+ from textual.binding import Binding
23
+ from textual.widgets import Static
24
+
25
+ from .runs import DecompIndex, group_batches, SourceRunIndex
26
+
27
+
28
+ class DecompApp(App):
29
+ """Decomp-batch setup, v0 thinnest slice: one selection stage over the
30
+ transcription-run corpus (RUNS), a read-only decomp-results view (RESULTS),
31
+ then exit with a grouped batch plan.
32
+
33
+ RUNS lists the transcription core's own run manifests newest-first; enter
34
+ toggles a run into the ORDERED batch (the row shows its queue position),
35
+ t cycles a multi-transcriber run's authoritative text_from (default: the
36
+ accuracy slot), and the batch panel below previews exactly how many
37
+ headless invocations the selection folds into — one per distinct
38
+ text_from, each riding ONE loaded capability stack. The app itself never
39
+ loads a capability: confirm exits with the plan and the driver hands off
40
+ to the decomp core's batch CLI, so TUI-queued runs are byte-identical to
41
+ hand-launched ones. RESULTS reads the decomp core's own manifests back
42
+ (list -> per-source drill); segment TEXTS live in the graph, not the
43
+ manifest, so deeper inspection waits for real-session demand.
44
+ """
45
+
46
+ AUTO_FOCUS = None
47
+
48
+ CSS = """
49
+ #main { height: 1fr; }
50
+ #status { dock: bottom; height: 1; }
51
+ """
52
+
53
+ BINDINGS = [
54
+ Binding("j", "move(1)", "down"),
55
+ Binding("down", "move(1)", "down", show=False),
56
+ Binding("k", "move(-1)", "up"),
57
+ Binding("up", "move(-1)", "up", show=False),
58
+ Binding("enter", "select", "pick/open"),
59
+ Binding("space", "select", "pick", show=False),
60
+ Binding("t", "cycle_text_from", "text-from"),
61
+ Binding("v", "results", "decomp runs"),
62
+ Binding("r", "reload", "reload"),
63
+ Binding("n", "confirm", "confirm batch"),
64
+ Binding("b", "back", "back"),
65
+ Binding("q", "quit_app", "quit"),
66
+ ]
67
+
68
+ REPAINT_INTERVAL = 1 / 30 # Coalescing window: at most ~30 full repaints/s
69
+
70
+ def __init__(self, manifests_dir: str, # Capability manifests directory
71
+ *, runs_dir: str = "runs", # Both cores' cwd-relative manifest dir
72
+ sysmon_capability: Optional[str] = None, # Monitor for GPU attribution (CR-7)
73
+ graph_capability: str = "cjm-capability-graph-sqlite", # Extension target
74
+ graph_db_path: Optional[str] = None): # Caller-wins graph db override
75
+ super().__init__()
76
+ self.manifests_dir = manifests_dir
77
+ self.sysmon_capability = sysmon_capability
78
+ self.graph_capability = graph_capability
79
+ self.graph_db_path = graph_db_path
80
+ self.src_index = SourceRunIndex(runs_dir)
81
+ self.dec_index = DecompIndex(runs_dir)
82
+ self.stage = "runs"
83
+ self.cursor = 0
84
+ # The batch is keyed by manifest _path (stable across r-reloads, where
85
+ # a list index would silently re-target after a re-sort).
86
+ self.picked: List[str] = []
87
+ self.text_from: Dict[str, str] = {} # _path -> explicit t-cycle override
88
+ self._decomp_counts: Dict[str, int] = {}
89
+ self.results_run: Optional[int] = None # None = decomp list; else drilled index
90
+ self.results_cursor = 0
91
+ self.results_src = 0
92
+ self.error: Optional[str] = None
93
+ self.notice: Optional[str] = None
94
+ self._throttle = RepaintThrottle(self._paint_now, self.set_timer,
95
+ self.REPAINT_INTERVAL)
96
+
97
+ def compose(self) -> ComposeResult:
98
+ yield Static(id="main")
99
+ yield Static(id="status")
100
+
101
+ def on_mount(self) -> None:
102
+ self._reload_indexes()
103
+ self._paint()
104
+
105
+ def on_resize(self, event) -> None:
106
+ self._paint()
107
+
108
+ # ---- painting (spans only — a base style on a composed row bleeds) ----
109
+
110
+ def _paint(self) -> None:
111
+ """Request a repaint, coalescing bursts (kit RepaintThrottle)."""
112
+ self._throttle.request()
113
+
114
+ def _paint_now(self) -> None:
115
+ pane = {"runs": self._paint_runs,
116
+ "results": self._paint_results}[self.stage]()
117
+ self.query_one("#main", Static).update(pane)
118
+ status = Text()
119
+ # Decomp ALWAYS journals — extension IS graph writing — so the chip
120
+ # shows the target, never a NOT-JOURNALED state (that state cannot
121
+ # exist for this workflow).
122
+ status.append(f" graph→{self.graph_capability} ", style="green")
123
+ if self.graph_db_path:
124
+ status.append(f"@{tail(self.graph_db_path, 28)} ", style="dim")
125
+ if self.error:
126
+ status.append(f" {self.error} ", style="bold red")
127
+ elif self.notice:
128
+ status.append(f" {self.notice} ", style="cyan")
129
+ else:
130
+ hints = {
131
+ "runs": "enter/space pick · t text-from · v decomp runs · r reload · n confirm · q quit",
132
+ "results": ("enter open run · j/k walk · b back · q quit"
133
+ if self.results_run is None
134
+ else "j/k source · b decomp list · q quit"),
135
+ }[self.stage]
136
+ status.append(f" {self.stage.upper()} · {hints}", style="dim")
137
+ status.truncate(max(20, self.size.width), overflow="ellipsis")
138
+ self.query_one("#status", Static).update(status)
139
+
140
+ def _paint_runs(self) -> Text:
141
+ # One screen line per listing row (row discipline: wrapped rows eat the
142
+ # windowing budget); the batch panel keeps a fixed tail below the list.
143
+ width = max(20, self.size.width)
144
+ out = Text()
145
+ runs = self.src_index.runs
146
+ out.append(f" Transcription runs ({len(runs)}) · {self.src_index.runs_dir}/\n\n",
147
+ style="bold")
148
+ if not runs:
149
+ out.append(" (no transcription run manifests found — confirmed "
150
+ "transcription runs land here)\n", style="dim")
151
+ batches = self._batches()
152
+ panel = (2 + len(batches)) if self.picked else 0
153
+ self.cursor = max(0, min(self.cursor, max(0, len(runs) - 1)))
154
+ # Detail region (614dd647): up to 4 source rows + the graph-db line.
155
+ detail = (min(4, len(runs[self.cursor].get("sources") or [])) + 3) if runs else 0
156
+ budget = max(3, max(4, self.size.height - 1) - 4 - panel - detail)
157
+ start, end, above, below = visible_slice(len(runs), self.cursor, budget)
158
+ if above:
159
+ out.append(f" … {above} above\n", style="dim")
160
+ picked_set = set(self.picked)
161
+ for i in range(start, end):
162
+ m = runs[i]
163
+ focus = (i == self.cursor)
164
+ key = m["_path"]
165
+ line = Text()
166
+ line.append(" > " if focus else " ", style="bold cyan" if focus else "dim")
167
+ if key in picked_set:
168
+ # Queue position, not just [x]: the batch is ORDERED.
169
+ line.append(f"[{self.picked.index(key) + 1}] ", style="green")
170
+ else:
171
+ line.append("[ ] ", style="dim")
172
+ when = time.strftime("%Y-%m-%d %H:%M",
173
+ time.localtime(float(m.get("created_at") or 0)))
174
+ line.append(str(m["run_id"]), style="bold" if focus else "")
175
+ line.append(f" {when} {len(m.get('sources') or [])} src · "
176
+ f"{SourceRunIndex.segment_count(m)} seg", style="dim")
177
+ names = [t.removeprefix("cjm-capability-")
178
+ for t in SourceRunIndex.transcribers(m)]
179
+ if names:
180
+ line.append(" " + "+".join(names), style="dim")
181
+ tf = self._resolved_text_from(m)
182
+ if len(names) > 1 and tf:
183
+ line.append(f" tf={tf.removeprefix('cjm-capability-')}",
184
+ style="yellow" if key in self.text_from else "dim cyan")
185
+ n = self._decomp_counts.get(str(Path(key).resolve()), 0)
186
+ if n:
187
+ line.append(f" ·decomp×{n}", style="dim cyan")
188
+ line.truncate(width, overflow="ellipsis")
189
+ out.append_text(line)
190
+ out.append("\n")
191
+ if below:
192
+ out.append(f" … {below} below\n", style="dim")
193
+ if runs:
194
+ # Focused-run detail (614dd647): WHAT was transcribed + WHICH graph
195
+ # it landed in, in-pane — identity must not need a round-trip to
196
+ # the transcription TUI.
197
+ focused = runs[self.cursor]
198
+ out.append("\n")
199
+ srcs = focused.get("sources") or []
200
+ for s in srcs[:4]:
201
+ nm = Path(str(s.get("source_path") or "?")).stem
202
+ row = Text()
203
+ row.append(f" · {nm}", style="bold")
204
+ row.append(f" {len(s.get('segments') or [])} seg", style="dim")
205
+ row.truncate(width, overflow="ellipsis")
206
+ out.append_text(row)
207
+ out.append("\n")
208
+ if len(srcs) > 4:
209
+ out.append(f" … {len(srcs) - 4} more source(s)\n", style="dim")
210
+ db = self._resolved_graph_db(focused)
211
+ dbline = Text()
212
+ if db is None:
213
+ dbline.append(" ⚠ no graph db recorded in this manifest",
214
+ style="bold red")
215
+ elif not Path(db).exists():
216
+ dbline.append(f" ⚠ recorded graph db missing on disk: {tail(db, 40)}",
217
+ style="bold red")
218
+ else:
219
+ dbline.append(f" graph db: {tail(db, 48)}", style="dim cyan")
220
+ dbline.truncate(width, overflow="ellipsis")
221
+ out.append_text(dbline)
222
+ out.append("\n")
223
+ if self.picked:
224
+ out.append(f"\n Batch ({len(self.picked)} run(s) -> {len(batches)} "
225
+ f"invocation(s)):\n", style="bold")
226
+ for bi, ((tf, db), paths) in enumerate(batches):
227
+ short = (tf or "?").removeprefix("cjm-capability-")
228
+ line = Text()
229
+ line.append(f" {bi + 1}. text-from {short}", style="green")
230
+ if db is None:
231
+ line.append(" ⚠ no graph db recorded", style="bold red")
232
+ elif not Path(db).exists():
233
+ line.append(f" ⚠ db missing: {tail(db, 24)}", style="bold red")
234
+ else:
235
+ line.append(f" db {tail(db, 24)}", style="dim")
236
+ line.append(": ", style="green")
237
+ line.append(", ".join(self._run_id_for(p) for p in paths), style="dim")
238
+ line.truncate(width, overflow="ellipsis")
239
+ out.append_text(line)
240
+ out.append("\n")
241
+ return out
242
+
243
+ def _paint_results(self) -> Text:
244
+ width = max(20, self.size.width)
245
+ out = Text()
246
+ runs = self.dec_index.runs
247
+ if self.results_run is None:
248
+ out.append(f" Decomp runs ({len(runs)}) · {self.dec_index.runs_dir}/\n\n",
249
+ style="bold")
250
+ if not runs:
251
+ out.append(" (no decomp manifests found — batch hand-offs land here)\n",
252
+ style="dim")
253
+ budget = max(3, max(4, self.size.height - 1) - 4)
254
+ start, end, above, below = visible_slice(len(runs), self.results_cursor,
255
+ budget)
256
+ if above:
257
+ out.append(f" … {above} above\n", style="dim")
258
+ for i in range(start, end):
259
+ m = runs[i]
260
+ focus = (i == self.results_cursor)
261
+ line = Text()
262
+ line.append(" > " if focus else " ",
263
+ style="bold cyan" if focus else "dim")
264
+ when = time.strftime("%Y-%m-%d %H:%M",
265
+ time.localtime(float(m.get("created_at") or 0)))
266
+ srcs = m.get("sources") or []
267
+ segs = sum(int(s.get("segment_count") or 0) for s in srcs)
268
+ line.append(str(m["run_id"]), style="bold" if focus else "")
269
+ line.append(f" {when} {len(srcs)} source(s) · {segs} seg", style="dim")
270
+ tf = (m.get("config") or {}).get("text_from")
271
+ if tf:
272
+ line.append(f" tf={str(tf).removeprefix('cjm-capability-')}",
273
+ style="dim cyan")
274
+ line.truncate(width, overflow="ellipsis")
275
+ out.append_text(line)
276
+ out.append("\n")
277
+ if below:
278
+ out.append(f" … {below} below\n", style="dim")
279
+ return out
280
+ m = runs[self.results_run]
281
+ header = Text()
282
+ header.append(f" {m['run_id']}", style="bold")
283
+ src_manifest = m.get("source_manifest")
284
+ if src_manifest:
285
+ header.append(f" <- {tail(str(src_manifest), 48)}", style="dim")
286
+ header.truncate(width, overflow="ellipsis")
287
+ out.append_text(header)
288
+ out.append("\n\n")
289
+ srcs = m.get("sources") or []
290
+ if not srcs:
291
+ out.append(" (no sources extended — the run failed or was aborted "
292
+ "before its first commit)\n", style="dim")
293
+ budget = max(3, max(4, self.size.height - 1) - 4)
294
+ self.results_src = max(0, min(self.results_src, max(0, len(srcs) - 1)))
295
+ start, end, above, below = visible_slice(len(srcs), self.results_src, budget)
296
+ if above:
297
+ out.append(f" … {above} above\n", style="dim")
298
+ for i in range(start, end):
299
+ s = srcs[i]
300
+ focus = (i == self.results_src)
301
+ line = Text()
302
+ line.append(" > " if focus else " ",
303
+ style="bold cyan" if focus else "dim")
304
+ line.append(str(s.get("title") or "?"), style="bold" if focus else "")
305
+ line.append(f" {int(s.get('segment_count') or 0)} fine segment(s)",
306
+ style="dim")
307
+ node = str(s.get("source_node_id") or "")
308
+ if node:
309
+ line.append(f" Source {node[:8]}…", style="dim cyan")
310
+ line.truncate(width, overflow="ellipsis")
311
+ out.append_text(line)
312
+ out.append("\n")
313
+ if below:
314
+ out.append(f" … {below} below\n", style="dim")
315
+ return out
316
+
317
+ # ---- selection state helpers (pure reads over the indexes) ----
318
+
319
+ def _run_by_path(self, key: str) -> Optional[Dict[str, Any]]:
320
+ """The loaded transcription manifest at a _path key (None after eviction)."""
321
+ for m in self.src_index.runs:
322
+ if m["_path"] == key:
323
+ return m
324
+ return None
325
+
326
+ def _run_id_for(self, key: str) -> str:
327
+ m = self._run_by_path(key)
328
+ return str(m["run_id"]) if m else Path(key).stem
329
+
330
+ def _resolved_text_from(self, m: Dict[str, Any]) -> Optional[str]:
331
+ """Effective authority pick: the operator's t-cycle override, else the
332
+ convention default (sole transcriber / the accuracy slot)."""
333
+ return (self.text_from.get(m["_path"])
334
+ or SourceRunIndex.default_text_from(m))
335
+
336
+ def _resolved_graph_db(self, m: Dict[str, Any]) -> Optional[str]:
337
+ """Effective graph db for one run: the explicit override (flag/state)
338
+ wins, else the db the run RECORDED writing to (e087d059 — provenance-
339
+ following, never the decomp stack's own configured default)."""
340
+ return (self.graph_db_path
341
+ or SourceRunIndex.recorded_graph_db(m, self.graph_capability))
342
+
343
+ def _batches(self) -> List[Tuple[Tuple[Optional[str], Optional[str]], List[str]]]:
344
+ """The hand-off fold: key = (text_from, graph_db_path) — everything
345
+ that applies invocation-wide on the core CLI."""
346
+ picks: List[Tuple[str, Tuple[Optional[str], Optional[str]]]] = []
347
+ for key in self.picked:
348
+ m = self._run_by_path(key)
349
+ if m is not None:
350
+ picks.append((key, (self._resolved_text_from(m),
351
+ self._resolved_graph_db(m))))
352
+ return group_batches(picks)
353
+
354
+ def _reload_indexes(self) -> None:
355
+ self.src_index.load()
356
+ self.dec_index.load()
357
+ self._decomp_counts = self.dec_index.counts_by_source_manifest()
358
+ # A reload may evict manifests the batch still names; drop those picks
359
+ # rather than hand off paths the core would refuse.
360
+ self.picked = [p for p in self.picked if self._run_by_path(p) is not None]
361
+
362
+ # ---- stage actions (single key vocabulary, stage-dispatched) ----
363
+
364
+ def action_move(self, delta: int) -> None:
365
+ if self.stage == "runs":
366
+ if self.src_index.runs:
367
+ self.cursor = max(0, min(self.cursor + delta,
368
+ len(self.src_index.runs) - 1))
369
+ elif self.results_run is None:
370
+ if self.dec_index.runs:
371
+ self.results_cursor = max(0, min(self.results_cursor + delta,
372
+ len(self.dec_index.runs) - 1))
373
+ else:
374
+ srcs = self.dec_index.runs[self.results_run].get("sources") or []
375
+ if srcs:
376
+ self.results_src = max(0, min(self.results_src + delta,
377
+ len(srcs) - 1))
378
+ self._paint()
379
+
380
+ def on_mouse_scroll_down(self, event) -> None:
381
+ """Wheel = the j/k cursor walk (transcription-TUI drive-2 lesson)."""
382
+ self.action_move(1)
383
+
384
+ def on_mouse_scroll_up(self, event) -> None:
385
+ self.action_move(-1)
386
+
387
+ def action_select(self) -> None:
388
+ if self.stage == "runs":
389
+ runs = self.src_index.runs
390
+ if not runs:
391
+ return
392
+ m = runs[self.cursor]
393
+ if not SourceRunIndex.transcribers(m):
394
+ # The core would refuse it at run time ("no transcribers");
395
+ # surfacing that at PICK time keeps the batch hand-off clean.
396
+ self.error = f"{m['run_id']}: manifest lists no transcribers"
397
+ self._paint()
398
+ return
399
+ key = m["_path"]
400
+ if key in self.picked:
401
+ self.picked.remove(key)
402
+ else:
403
+ self.picked.append(key)
404
+ self.error = None
405
+ elif self.results_run is None and self.dec_index.runs:
406
+ self.results_run = self.results_cursor
407
+ self.results_src = 0
408
+ self._paint()
409
+
410
+ def action_cycle_text_from(self) -> None:
411
+ """Cycle the focused run's authoritative transcriber (t).
412
+
413
+ Only multi-transcriber runs have a choice to make — the layer-0 text
414
+ the fine spine commits comes from exactly one of them (--text-from),
415
+ so the pick belongs HERE, per run, not as one flag over the batch;
416
+ grouping folds equal picks back into shared invocations."""
417
+ if self.stage != "runs" or not self.src_index.runs:
418
+ return
419
+ m = self.src_index.runs[self.cursor]
420
+ ts = SourceRunIndex.transcribers(m)
421
+ if len(ts) < 2:
422
+ self.notice = "single-transcriber run — text-from is its sole transcriber"
423
+ self._paint()
424
+ return
425
+ cur = self._resolved_text_from(m)
426
+ nxt = ts[(ts.index(cur) + 1) % len(ts)] if cur in ts else ts[-1]
427
+ self.text_from[m["_path"]] = nxt
428
+ self.notice = None
429
+ self._paint()
430
+
431
+ def action_results(self) -> None:
432
+ """Open the decomp-runs view (v): re-reads the manifests so a batch
433
+ that just finished in another terminal shows without a restart."""
434
+ if self.stage != "runs":
435
+ return
436
+ self.notice = None
437
+ self.error = None
438
+ self._reload_indexes()
439
+ self.results_run = None
440
+ self.results_cursor = 0
441
+ self.stage = "results"
442
+ self._paint()
443
+
444
+ def action_reload(self) -> None:
445
+ if self.stage != "runs":
446
+ return
447
+ self._reload_indexes()
448
+ self.notice = (f"reloaded: {len(self.src_index.runs)} transcription / "
449
+ f"{len(self.dec_index.runs)} decomp run(s)")
450
+ self._paint()
451
+
452
+ def action_back(self) -> None:
453
+ if self.stage == "results":
454
+ if self.results_run is not None:
455
+ self.results_run = None # drilled run -> back to the decomp list
456
+ else:
457
+ self.stage = "runs"
458
+ self._paint()
459
+
460
+ def action_confirm(self) -> None:
461
+ if self.stage != "runs":
462
+ return
463
+ if not self.picked:
464
+ self.error = "pick at least one transcription run first"
465
+ self._paint()
466
+ return
467
+ for (tf, db), paths in self._batches():
468
+ if db is None:
469
+ # Proceeding without a db is the guaranteed-wrong-graph failure
470
+ # the first drive hit (e087d059) — block, don't warn.
471
+ self.error = (f"no graph db recorded for {self._run_id_for(paths[0])} "
472
+ "— pass --graph-db-path to override")
473
+ self._paint()
474
+ return
475
+ self.exit({
476
+ "batches": [{"text_from": tf, "graph_db_path": db,
477
+ "manifests": list(paths)}
478
+ for (tf, db), paths in self._batches()],
479
+ "runs_dir": str(self.src_index.runs_dir),
480
+ "manifests_dir": self.manifests_dir,
481
+ "sysmon_capability": self.sysmon_capability,
482
+ "graph_capability": self.graph_capability,
483
+ "graph_db_path": self.graph_db_path,
484
+ })
485
+
486
+ def action_quit_app(self) -> None:
487
+ self.exit(None)
@@ -0,0 +1,142 @@
1
+ """The console-script driver: run the batch-selection TUI, then hand each
2
+ text-from group to the HEADLESS decomp core CLI in-process (terminal restored
3
+ first). Every group's equivalent cjm-transcript-decomp-core command prints
4
+ before execution — TUI-queued batches stay reproducible by copy-paste — and
5
+ --plan-only stops at the printout."""
6
+
7
+ import argparse
8
+ import os
9
+ import shlex
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from cjm_substrate.core.workspace import resolve_workspace
13
+ from cjm_transcript_decomp_core.cli import main as core_main
14
+
15
+ from .app import DecompApp
16
+ from .discovery import discover_capability
17
+ from .state import load_state, save_state
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser: # Configured CLI parser
21
+ """The TUI driver's argument surface (batch-setup options + core passthrough)."""
22
+ p = argparse.ArgumentParser(
23
+ prog="cjm-transcript-decomp-tui",
24
+ description="Batch-setup TUI for the headless decomp pipeline: pick "
25
+ "transcription runs into an ordered batch (with per-run "
26
+ "authoritative-text choices), then hand off to "
27
+ "cjm-transcript-decomp-core's batch runner — one loaded "
28
+ "capability stack per text-from group.")
29
+ p.add_argument("--workspace", default=None,
30
+ help="Workspace root (5daadfc4; default: CJM_WORKSPACE env, else upward walk "
31
+ "from cwd). Supplies runs/manifests defaults and is exported so the "
32
+ "core hand-off + capability workers resolve workspace-scoped paths")
33
+ p.add_argument("--runs-dir", default=None,
34
+ help="Run-manifest directory (default: the workspace's runs/ when one is "
35
+ "active, else runs/ under the cwd — both cores' legacy default)")
36
+ p.add_argument("--manifests-dir", default=None,
37
+ help="Capability manifests directory (default: the workspace's "
38
+ ".cjm/manifests when one is active, else .cjm/manifests under the cwd)")
39
+ p.add_argument("--vad-capability", default="cjm-capability-silero-vad",
40
+ help="VAD capability name (forwarded to the core)")
41
+ p.add_argument("--fa-capability", default="cjm-capability-qwen3-forced-aligner",
42
+ help="Forced-alignment capability name (forwarded to the core)")
43
+ p.add_argument("--graph-capability", default="cjm-capability-graph-sqlite",
44
+ help="Graph-storage capability the fine spine extends "
45
+ "(decomp REQUIRES one — there is no unjournaled decomp)")
46
+ p.add_argument("--graph-db-path", default=None,
47
+ help="Explicit graph db path (default: last-used, else the "
48
+ "capability's configured db_path)")
49
+ p.add_argument("--sysmon-capability", default=None,
50
+ help="monitor capability for GPU attribution (default: last-used, "
51
+ "else auto-discovered from manifests)")
52
+ p.add_argument("--no-sysmon", action="store_true",
53
+ help="Explicitly disable the monitor (overrides state + discovery)")
54
+ p.add_argument("--language", default="English",
55
+ help="Forced-alignment language (forwarded to the core)")
56
+ p.add_argument("--force", action="store_true",
57
+ help="Bypass capability-side caches (forwarded to the core)")
58
+ p.add_argument("--actor", default=None,
59
+ help="Forwarded journal attribution (default: cli:<user>)")
60
+ p.add_argument("--plan-only", action="store_true",
61
+ help="Print each group's equivalent headless command and exit "
62
+ "WITHOUT running anything")
63
+ return p
64
+
65
+
66
+ def batch_argv(
67
+ batch: Dict[str, Any], # One confirmed group: {"text_from", "graph_db_path", "manifests"}
68
+ args: argparse.Namespace, # The TUI's parsed args (passthrough run options)
69
+ sysmon: Optional[str], # Resolved monitor capability (None = disabled)
70
+ ) -> List[str]: # cjm-transcript-decomp-core argv (the reproducibility contract)
71
+ """Render one hand-off group as headless decomp-core argv.
72
+
73
+ Everything the TUI decided (the ordered member manifests, the group's
74
+ authoritative transcriber, the group's graph db — the one the SOURCE runs
75
+ recorded writing to, e087d059) plus everything it merely passes through
76
+ (capabilities, language, force, sysmon, actor) lands in ONE argv — printed
77
+ before execution so any TUI-queued batch can be replayed by hand.
78
+ --output-dir pins decomp manifests to the SAME runs dir the TUI browsed
79
+ (7dfd1177: cwd-relative output blinded the results view and the coverage
80
+ chips).
81
+ """
82
+ argv = ["run", *batch["manifests"], "--yes",
83
+ "--manifests-dir", args.manifests_dir,
84
+ "--vad-capability", args.vad_capability,
85
+ "--fa-capability", args.fa_capability,
86
+ "--graph-capability", args.graph_capability,
87
+ "--language", args.language,
88
+ "--output-dir", args.runs_dir]
89
+ if batch.get("text_from"):
90
+ argv += ["--text-from", batch["text_from"]]
91
+ if batch.get("graph_db_path"):
92
+ argv += ["--graph-db-path", batch["graph_db_path"]]
93
+ if sysmon:
94
+ argv += ["--sysmon-capability", sysmon]
95
+ if args.force:
96
+ argv += ["--force"]
97
+ if args.actor:
98
+ argv += ["--actor", args.actor]
99
+ return argv
100
+
101
+
102
+ def main() -> int: # Console-script entry point (cjm-transcript-decomp-tui)
103
+ """Resolve settings (flags > persisted state > manifest discovery), run the
104
+ batch app, persist the confirmed choices, then print + run each group."""
105
+ args = build_parser().parse_args()
106
+ # 5daadfc4 workspace: resolve before anything reads paths; export so the
107
+ # in-process core hand-off + capability workers are workspace-scoped.
108
+ ws = resolve_workspace(explicit=args.workspace)
109
+ if ws is not None:
110
+ os.environ["CJM_WORKSPACE"] = str(ws.root)
111
+ if args.manifests_dir is None:
112
+ args.manifests_dir = (str(ws.substrate_data_dir / "manifests")
113
+ if ws is not None else ".cjm/manifests")
114
+ if args.runs_dir is None:
115
+ args.runs_dir = str(ws.runs_dir) if ws is not None else "runs"
116
+ state = load_state(args.manifests_dir)
117
+ sysmon = None if args.no_sysmon else (
118
+ args.sysmon_capability or state.get("sysmon_capability")
119
+ or discover_capability(args.manifests_dir, "get_system_status"))
120
+ graph_db_path = args.graph_db_path or state.get("graph_db_path")
121
+ app = DecompApp(args.manifests_dir, runs_dir=args.runs_dir,
122
+ sysmon_capability=sysmon,
123
+ graph_capability=args.graph_capability,
124
+ graph_db_path=graph_db_path)
125
+ plan = app.run()
126
+ if not plan:
127
+ print("no batch confirmed")
128
+ return 0
129
+ save_state(args.manifests_dir,
130
+ sysmon_capability=plan["sysmon_capability"],
131
+ graph_db_path=plan["graph_db_path"])
132
+ rc = 0
133
+ for i, batch in enumerate(plan["batches"]):
134
+ argv = batch_argv(batch, args, plan["sysmon_capability"])
135
+ print(f"batch {i + 1}/{len(plan['batches'])}: "
136
+ + shlex.join(["cjm-transcript-decomp-core"] + argv))
137
+ if args.plan_only:
138
+ continue
139
+ # Sequential by design: groups share the GPU; the core loads one stack
140
+ # per group and its exit code aggregates that group's members.
141
+ rc = max(rc, int(core_main(argv)))
142
+ return rc
@@ -0,0 +1,55 @@
1
+ """Capability-role discovery by manifest surface match — the journaling-by-
2
+ default mechanism (sysmon here; the decomp core REQUIRES its graph capability,
3
+ so only the monitor role auto-discovers). CARRIED COPY of the transcription
4
+ TUI's candidates.py pair, kept deliberately close to verbatim: this second
5
+ consumer is the N=2 promotion signal for cjm-substrate-tui-kit (cross-repo
6
+ move v2 is the vehicle), and a drifted copy would fork the discovery contract
7
+ before the move lands."""
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+
14
+ def manifests_with_method(
15
+ manifests_dir: str, # Capability manifests directory (the core CLI's --manifests-dir)
16
+ method: str, # Structural-surface method that identifies the role
17
+ ) -> Dict[str, Dict[str, Any]]: # capability name -> its manifest `code` section
18
+ """Enumerate installed capabilities whose structural surface lists `method`.
19
+
20
+ Capabilities qualify by SURFACE, not by name — the same signal the
21
+ substrate's adapter auto-binding matches against a task protocol, read
22
+ cheaply off the manifest json (no worker spawn). Role key in use here:
23
+ `get_system_status` (system monitor). Adapter unit manifests carry no
24
+ `code` section and are skipped; unreadable files are skipped rather than
25
+ failing enumeration.
26
+ """
27
+ out: Dict[str, Dict[str, Any]] = {}
28
+ for f in sorted(Path(manifests_dir).glob("*.json")):
29
+ try:
30
+ manifest = json.loads(f.read_text())
31
+ except (OSError, ValueError):
32
+ continue
33
+ code = manifest.get("code") if isinstance(manifest, dict) else None
34
+ if not isinstance(code, dict):
35
+ continue
36
+ methods = ((code.get("structural_surface") or {}).get("methods") or [])
37
+ if any(m.get("name") == method for m in methods):
38
+ out[code.get("name") or f.stem] = code
39
+ return out
40
+
41
+
42
+ def discover_capability(
43
+ manifests_dir: str, # Capability manifests directory
44
+ method: str, # Surface method that identifies the role
45
+ ) -> Optional[str]: # First matching capability name (sorted), or None
46
+ """Pick a DEFAULT capability for a role by surface match.
47
+
48
+ Journaling-by-default's mechanism (transcription-TUI drive-1 finding):
49
+ when the runtime has a monitor installed, the TUI should use it without
50
+ being told — forgetting must take an explicit opt-out, not a forgotten
51
+ flag. Sorted-first keeps the pick deterministic when several qualify; the
52
+ operator's persisted choice (state.py) wins over discovery.
53
+ """
54
+ names = sorted(manifests_with_method(manifests_dir, method))
55
+ return names[0] if names else None
@@ -0,0 +1,174 @@
1
+ """Run-manifest indexes for the decomp-batch TUI (work item 0ff6bf0f): the
2
+ transcription core's own runs/*.json read into a selectable batch (decomp
3
+ consumes RUN MANIFESTS, not media files), the decomp core's own manifests read
4
+ back as coverage chips + results rows, and the pure grouping fold the confirm
5
+ hand-off uses. Pure logic, Textual-free (the transcription TUI's results.py
6
+ precedent: everything below the paint path tests directly; the app only paints
7
+ it). Both cores share the cwd-relative runs/ default, so the manifest FORMAT
8
+ tag — the manifest-as-interchange contract (CR-20) — is what separates
9
+ transcription runs from decomp runs living in one directory."""
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ from cjm_substrate.core.workspace import resolve_recorded_tree
16
+
17
+
18
+ def _load_manifests(
19
+ runs_dir: Path, # Directory holding both cores' run manifests
20
+ format_tag: str, # Substring the manifest's format tag must carry
21
+ ) -> List[Dict[str, Any]]: # Matching manifest dicts, newest first (+ "_path")
22
+ """Read every readable manifest in runs_dir whose format tag matches.
23
+
24
+ The RunIndex forgiveness contract (transcription TUI results.py): the runs
25
+ dir is shared ground — unreadable/foreign jsons are skipped, never raised,
26
+ because one corrupt file must not hide the rest. The format-tag filter is
27
+ load-bearing here where RunIndex could skip it: BOTH cores write manifests
28
+ with run_id + sources into the same directory, so shape alone no longer
29
+ separates them."""
30
+ rows: List[Dict[str, Any]] = []
31
+ try:
32
+ files = sorted(runs_dir.glob("*.json"))
33
+ except OSError:
34
+ files = []
35
+ for f in files:
36
+ try:
37
+ # ${WS}/ recorded paths (5daadfc4 rung f) resolve at load,
38
+ # anchored at the manifest's own location.
39
+ m = resolve_recorded_tree(json.loads(f.read_text()), f)
40
+ except (OSError, ValueError):
41
+ continue
42
+ if not (isinstance(m, dict) and m.get("run_id")
43
+ and format_tag in str(m.get("format", ""))
44
+ and isinstance(m.get("sources"), list)):
45
+ continue
46
+ m["_path"] = str(f)
47
+ rows.append(m)
48
+ rows.sort(key=lambda m: float(m.get("created_at") or 0.0), reverse=True)
49
+ return rows
50
+
51
+
52
+ class SourceRunIndex:
53
+ """Transcription-core run manifests — the decomp workflow's SOURCES — plus
54
+ the per-run facts the batch stage paints: transcriber lists, segment
55
+ totals, and the authoritative-text default. Reads what the transcription
56
+ runs wrote, no parallel record (results-layer principle)."""
57
+
58
+ FORMAT_TAG = "transcription-core"
59
+
60
+ def __init__(self, runs_dir: str = "runs"): # Both cores' cwd-relative default
61
+ self.runs_dir = Path(runs_dir)
62
+ self.runs: List[Dict[str, Any]] = [] # Manifest dicts, newest first (+ "_path")
63
+
64
+ def load(self) -> int: # Number of manifests loaded
65
+ """(Re)read every readable transcription-run manifest, newest first."""
66
+ self.runs = _load_manifests(self.runs_dir, self.FORMAT_TAG)
67
+ return len(self.runs)
68
+
69
+ @staticmethod
70
+ def transcribers(m: Dict[str, Any]) -> List[str]: # Transcriber instance ids, manifest order
71
+ """The run's transcriber ids (config snapshot; pre-0.2.0 single-key
72
+ manifests fold to a one-element list — the pipeline's own tolerance)."""
73
+ cfg = m.get("config") or {}
74
+ ids = cfg.get("transcriber_capabilities") or []
75
+ out = [str(i) for i in ids] if isinstance(ids, list) else []
76
+ if not out and cfg.get("transcriber_capability"):
77
+ out = [str(cfg["transcriber_capability"])]
78
+ return out
79
+
80
+ @staticmethod
81
+ def segment_count(m: Dict[str, Any]) -> int: # Pipeline segments across all sources
82
+ """Total pipeline segments in the run (the batch-size signal a row paints)."""
83
+ return sum(len(s.get("segments") or []) for s in m.get("sources") or [])
84
+
85
+ @classmethod
86
+ def default_text_from(cls, m: Dict[str, Any]) -> Optional[str]: # Pre-picked authoritative transcriber
87
+ """The default --text-from pick: the sole transcriber, else the LAST.
88
+
89
+ The transcription TUI's confirmed pair lands [lightweight, accuracy],
90
+ so last = the accuracy model — the natural layer-0 authority. A
91
+ CONVENTION default only: the row paints it and t cycles it, so a
92
+ hand-built manifest with a different order is one keypress away."""
93
+ t = cls.transcribers(m)
94
+ return t[-1] if t else None
95
+
96
+ @staticmethod
97
+ def recorded_graph_db(
98
+ m: Dict[str, Any], # A transcription-run manifest
99
+ graph_capability: str, # The graph capability's instance id
100
+ ) -> Optional[str]: # The db this run recorded writing to (None = not recorded)
101
+ """The graph db the transcription run RECORDED writing to.
102
+
103
+ The manifest's capabilities block is the provenance (finding e087d059:
104
+ defaulting to the decomp stack's own configured db pointed the first
105
+ live batch at the WRONG graph — 'Source root not found'). None means a
106
+ pre-provenance or unjournaled run: the caller must WARN, never
107
+ silently default."""
108
+ cap = (m.get("capabilities") or {}).get(graph_capability) or {}
109
+ db = cap.get("db_path")
110
+ return str(db) if db else None
111
+
112
+ @staticmethod
113
+ def source_names(m: Dict[str, Any]) -> List[str]: # Source display names, manifest order
114
+ """Path stems of the run's sources (finding 614dd647: a run row must
115
+ say WHAT was transcribed without a transcription-TUI round-trip)."""
116
+ return [Path(str(s.get("source_path") or "?")).stem
117
+ for s in m.get("sources") or []]
118
+
119
+
120
+ class DecompIndex:
121
+ """Decomp-core run manifests read back: coverage chips for the batch stage
122
+ (which transcription runs already have a decomp run) and the results
123
+ view's rows. v0 stops at the manifest's own facts — segment TEXTS live in
124
+ the graph, not the manifest, so deeper inspection views wait for real
125
+ decomp-session demand (the work item's deliberate unshaping)."""
126
+
127
+ FORMAT_TAG = "transcript-decomp-core"
128
+
129
+ def __init__(self, runs_dir: str = "runs"): # Both cores' cwd-relative default
130
+ self.runs_dir = Path(runs_dir)
131
+ self.runs: List[Dict[str, Any]] = [] # Manifest dicts, newest first (+ "_path")
132
+
133
+ def load(self) -> int: # Number of manifests loaded
134
+ """(Re)read every readable decomp-run manifest, newest first."""
135
+ self.runs = _load_manifests(self.runs_dir, self.FORMAT_TAG)
136
+ return len(self.runs)
137
+
138
+ def counts_by_source_manifest(self) -> Dict[str, int]:
139
+ """resolved transcription-manifest path -> decomp runs that consumed it.
140
+
141
+ Path-keyed and hash-free (the prior-run-chip pattern: browse-time chips
142
+ must stay cheap); the decomp core records source_manifest RESOLVED, so
143
+ resolving the browse key on lookup matches regardless of how the runs
144
+ dir was spelled."""
145
+ counts: Dict[str, int] = {}
146
+ for m in self.runs:
147
+ p = m.get("source_manifest")
148
+ if p:
149
+ key = str(Path(p).resolve())
150
+ counts[key] = counts.get(key, 0) + 1
151
+ return counts
152
+
153
+
154
+ def group_batches(
155
+ picks: List[Tuple[str, Any]], # Ordered (manifest_path, hand-off key) selection
156
+ ) -> List[Tuple[Any, List[str]]]: # (key, member paths) per hand-off invocation
157
+ """Fold an ordered batch selection into headless hand-off groups.
158
+
159
+ The key is EVERYTHING that applies invocation-wide on the core CLI —
160
+ currently (text_from, graph_db_path): --text-from names one authority per
161
+ invocation, and --graph-db-path points one graph per invocation (finding
162
+ e087d059 — runs recorded against different dbs must not share a stack
163
+ config). One core invocation per DISTINCT key is the finest split that
164
+ keeps the ergonomic win: every manifest in a group rides the SAME loaded
165
+ capability stack. First-seen order of groups and members preserves the
166
+ operator's queueing order."""
167
+ order: List[Any] = []
168
+ groups: Dict[Any, List[str]] = {}
169
+ for path, key in picks:
170
+ if key not in groups:
171
+ groups[key] = []
172
+ order.append(key)
173
+ groups[key].append(path)
174
+ return [(key, groups[key]) for key in order]
@@ -0,0 +1,35 @@
1
+ """Sidecar TUI state: last-used batch settings persisted across sessions (the
2
+ transcription TUI's state pattern — settings the operator picked once, the
3
+ sysmon choice and the graph db override, must never need retyping). The file
4
+ lives next to the manifests dir (one level up — .cjm/ in practice) so state is
5
+ per-project, not per-user."""
6
+
7
+ from pathlib import Path
8
+ from typing import Any, Dict
9
+
10
+ from cjm_substrate_tui_kit.state import SidecarState
11
+
12
+ STATE_BASENAME = "decomp-tui-state.json"
13
+
14
+
15
+ def state_path(
16
+ manifests_dir: str, # Capability manifests directory (the state key)
17
+ ) -> Path: # The sidecar state file (manifests dir's PARENT — .cjm/ in practice)
18
+ """Where this project's TUI state lives."""
19
+ return Path(manifests_dir).resolve().parent / STATE_BASENAME
20
+
21
+
22
+ def load_state(
23
+ manifests_dir: str, # Capability manifests directory (the state key)
24
+ ) -> Dict[str, Any]: # Persisted state ({} when absent/unreadable — never raises)
25
+ """Read this project's persisted TUI state."""
26
+ return SidecarState(state_path(manifests_dir)).load()
27
+
28
+
29
+ def save_state(
30
+ manifests_dir: str, # Capability manifests directory (the state key)
31
+ **updates: Any, # Keys to merge into the persisted state
32
+ ) -> Dict[str, Any]: # The merged state as written
33
+ """Merge updates into the persisted state and write it back (best-effort:
34
+ a read-only location must not break the batch hand-off)."""
35
+ return SidecarState(state_path(manifests_dir)).save(**updates)
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: cjm-transcript-decomp-tui
3
+ Version: 0.0.1
4
+ Summary: Decomp-batch TUI driver: browse transcription-core run manifests, queue an ordered multi-run batch with per-run authoritative-text picks, then hand off to cjm-transcript-decomp-core's headless batch runner (one capability stack for the whole batch). Born on-graph.
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-decomp-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
+ Requires-Dist: cjm-substrate-tui-kit>=0.0.1
13
+ Requires-Dist: cjm-transcript-decomp-core>=0.0.4
14
+ Requires-Dist: textual
15
+
16
+ # cjm-transcript-decomp-tui
17
+
18
+ <!-- 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) -->
19
+
20
+ Decomp-batch TUI driver for the transcript-decomposition workflow: browse the transcription core's run manifests, queue an ordered multi-run batch with per-run authoritative-text (text-from) picks, preview how the batch folds into headless invocations, then hand off to cjm-transcript-decomp-core's batch runner — one loaded capability stack per text-from group, every run byte-identical to a hand-launched one. Includes a read-only decomp-results view over the decomp core's own manifests.
21
+
22
+ ## Modules
23
+
24
+ - **`cjm_transcript_decomp_tui.__init__`**
25
+ - **`cjm_transcript_decomp_tui.app`**
26
+ - **`cjm_transcript_decomp_tui.cli`**
27
+ - **`cjm_transcript_decomp_tui.discovery`**
28
+ - **`cjm_transcript_decomp_tui.runs`**
29
+ - **`cjm_transcript_decomp_tui.state`**
30
+
31
+ ## API
32
+
33
+ ### `cjm_transcript_decomp_tui.app`
34
+
35
+ - `DecompApp` _class_ — Decomp-batch setup, v0 thinnest slice: one selection stage over the
36
+
37
+ ### `cjm_transcript_decomp_tui.cli`
38
+
39
+ - `batch_argv` _function_ — Render one text-from group as headless decomp-core argv.
40
+ - `build_parser` _function_ — The TUI driver's argument surface (batch-setup options + core passthrough).
41
+ - `main` _function_ — Resolve settings (flags > persisted state > manifest discovery), run the
42
+
43
+ ### `cjm_transcript_decomp_tui.discovery`
44
+
45
+ - `discover_capability` _function_ — Pick a DEFAULT capability for a role by surface match.
46
+ - `manifests_with_method` _function_ — Enumerate installed capabilities whose structural surface lists `method`.
47
+
48
+ ### `cjm_transcript_decomp_tui.runs`
49
+
50
+ - `DecompIndex` _class_ — Decomp-core run manifests read back: coverage chips for the batch stage
51
+ - `SourceRunIndex` _class_ — Transcription-core run manifests — the decomp workflow's SOURCES — plus
52
+ - `group_by_text_from` _function_ — Fold an ordered batch selection into headless hand-off groups.
53
+
54
+ ### `cjm_transcript_decomp_tui.state`
55
+
56
+ - `load_state` _function_ — Read this project's persisted TUI state.
57
+ - `save_state` _function_ — Merge updates into the persisted state and write it back (best-effort:
58
+ - `state_path` _function_ — Where this project's TUI state lives.
59
+
60
+ ## Dependencies
61
+
62
+ **Depends on:** `cjm-substrate-tui-kit`, `cjm-transcript-decomp-core`, `textual`
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ cjm_transcript_decomp_tui/__init__.py
4
+ cjm_transcript_decomp_tui/app.py
5
+ cjm_transcript_decomp_tui/cli.py
6
+ cjm_transcript_decomp_tui/discovery.py
7
+ cjm_transcript_decomp_tui/runs.py
8
+ cjm_transcript_decomp_tui/state.py
9
+ cjm_transcript_decomp_tui.egg-info/PKG-INFO
10
+ cjm_transcript_decomp_tui.egg-info/SOURCES.txt
11
+ cjm_transcript_decomp_tui.egg-info/dependency_links.txt
12
+ cjm_transcript_decomp_tui.egg-info/entry_points.txt
13
+ cjm_transcript_decomp_tui.egg-info/requires.txt
14
+ cjm_transcript_decomp_tui.egg-info/top_level.txt
15
+ tests/test_batch_logic.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cjm-transcript-decomp-tui = cjm_transcript_decomp_tui.cli:main
@@ -0,0 +1,3 @@
1
+ cjm-substrate-tui-kit>=0.0.1
2
+ cjm-transcript-decomp-core>=0.0.4
3
+ textual
@@ -0,0 +1 @@
1
+ cjm_transcript_decomp_tui
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cjm-transcript-decomp-tui"
7
+ version = "0.0.1"
8
+ description = "Decomp-batch TUI driver: browse transcription-core run manifests, queue an ordered multi-run batch with per-run authoritative-text picks, then hand off to cjm-transcript-decomp-core's headless batch runner (one capability stack for the whole batch). Born on-graph."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = {text = "Apache-2.0"}
12
+ authors = [{name = "Christian J. Mills", email = "9126128+cj-mills@users.noreply.github.com"}]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ ]
17
+ dependencies = [
18
+ 'cjm-substrate-tui-kit>=0.0.1',
19
+ 'cjm-transcript-decomp-core>=0.0.4',
20
+ 'textual',
21
+ ]
22
+
23
+ [project.scripts]
24
+ cjm-transcript-decomp-tui = "cjm_transcript_decomp_tui.cli:main"
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/cj-mills/cjm-transcript-decomp-tui"
28
+
29
+ [tool.setuptools.packages.find]
30
+ include = ["cjm_transcript_decomp_tui*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,124 @@
1
+ """Tests for the decomp-batch TUI's pure logic: manifest-format filtering, the
2
+ text-from default + grouping fold, coverage counts, and the hand-off argv
3
+ (everything below the paint path; the paint layer's verification is the
4
+ tests_manual pilot probe, per the TUI craft register)."""
5
+
6
+ import json
7
+ from pathlib import Path
8
+ from cjm_transcript_decomp_tui.cli import batch_argv, build_parser
9
+ from cjm_transcript_decomp_tui.runs import (DecompIndex, SourceRunIndex,
10
+ group_batches)
11
+
12
+
13
+ def _src_manifest(run_id, created, transcribers):
14
+ # Minimal but format-faithful transcription-core manifest (0.2.0 shape).
15
+ return {"format": "cjm-transcription-core/run-manifest", "version": "0.2.0",
16
+ "run_id": run_id, "created_at": created,
17
+ "config": {"transcriber_capabilities": transcribers},
18
+ "sources": [{"source_path": f"/tmp/{run_id}.mp3",
19
+ "segments": [{"index": 0}, {"index": 1}]}]}
20
+
21
+
22
+ def _dec_manifest(run_id, created, source_manifest):
23
+ # Minimal but format-faithful decomp-core manifest (0.2.1 shape).
24
+ return {"format": "cjm-transcript-decomp-core/run-manifest", "version": "0.2.1",
25
+ "run_id": run_id, "created_at": created,
26
+ "config": {"text_from": None},
27
+ "source_manifest": source_manifest,
28
+ "sources": [{"source_node_id": "abc123", "source_path": "/tmp/x.mp3",
29
+ "title": "x", "segment_count": 5, "segment_ids": []}]}
30
+
31
+
32
+ def test_format_filtering_and_order(tmp_path):
33
+ # Both cores' manifests + junk share one runs dir; the format tag separates.
34
+ (tmp_path / "a.json").write_text(json.dumps(_src_manifest("run_a", 100.0, ["w"])))
35
+ (tmp_path / "b.json").write_text(json.dumps(_src_manifest("run_b", 200.0, ["w"])))
36
+ (tmp_path / "d.json").write_text(
37
+ json.dumps(_dec_manifest("decomp_x", 300.0, str(tmp_path / "a.json"))))
38
+ (tmp_path / "junk.json").write_text("not json")
39
+ (tmp_path / "foreign.json").write_text(json.dumps({"run_id": "x", "sources": []}))
40
+ src = SourceRunIndex(str(tmp_path))
41
+ assert src.load() == 2
42
+ assert [m["run_id"] for m in src.runs] == ["run_b", "run_a"] # newest first
43
+ assert SourceRunIndex.segment_count(src.runs[0]) == 2
44
+ dec = DecompIndex(str(tmp_path))
45
+ assert dec.load() == 1
46
+ assert dec.runs[0]["run_id"] == "decomp_x"
47
+
48
+
49
+ def test_decomp_coverage_counts(tmp_path):
50
+ a = tmp_path / "a.json"
51
+ a.write_text(json.dumps(_src_manifest("run_a", 1.0, ["w"])))
52
+ (tmp_path / "d1.json").write_text(json.dumps(_dec_manifest("decomp_1", 2.0, str(a))))
53
+ (tmp_path / "d2.json").write_text(json.dumps(_dec_manifest("decomp_2", 3.0, str(a))))
54
+ dec = DecompIndex(str(tmp_path))
55
+ dec.load()
56
+ assert dec.counts_by_source_manifest() == {str(a.resolve()): 2}
57
+
58
+
59
+ def test_transcribers_and_default_text_from():
60
+ multi = _src_manifest("m", 1.0, ["cap-light", "cap-acc"])
61
+ assert SourceRunIndex.transcribers(multi) == ["cap-light", "cap-acc"]
62
+ # The confirmed pair lands [lightweight, accuracy] -> last = accuracy slot.
63
+ assert SourceRunIndex.default_text_from(multi) == "cap-acc"
64
+ legacy = {"config": {"transcriber_capability": "cap-solo"}}
65
+ assert SourceRunIndex.transcribers(legacy) == ["cap-solo"]
66
+ assert SourceRunIndex.default_text_from(legacy) == "cap-solo"
67
+ assert SourceRunIndex.default_text_from({"config": {}}) is None
68
+
69
+
70
+ def test_group_batches_preserves_order():
71
+ # The key is (text_from, graph_db_path) — everything invocation-wide.
72
+ groups = group_batches([("m1", ("acc", "/g1.db")), ("m2", ("solo", "/g1.db")),
73
+ ("m3", ("acc", "/g1.db")), ("m4", ("acc", "/g2.db"))])
74
+ assert groups == [(("acc", "/g1.db"), ["m1", "m3"]),
75
+ (("solo", "/g1.db"), ["m2"]),
76
+ (("acc", "/g2.db"), ["m4"])] # same authority, other db: own group
77
+ assert group_batches([]) == []
78
+
79
+
80
+ def test_batch_argv_renders_the_handoff():
81
+ args = build_parser().parse_args(["--runs-dir", "/data/runs", "--force",
82
+ "--actor", "tui:test"])
83
+ argv = batch_argv({"text_from": "cap-acc", "graph_db_path": "/tmp/g.db",
84
+ "manifests": ["a.json", "b.json"]},
85
+ args, "cjm-capability-monitor-nvidia")
86
+ assert argv[:3] == ["run", "a.json", "b.json"]
87
+ assert "--yes" in argv
88
+ assert argv[argv.index("--text-from") + 1] == "cap-acc"
89
+ assert argv[argv.index("--graph-db-path") + 1] == "/tmp/g.db"
90
+ assert argv[argv.index("--sysmon-capability") + 1] == "cjm-capability-monitor-nvidia"
91
+ # 7dfd1177: decomp manifests pin to the SAME dir the TUI browsed.
92
+ assert argv[argv.index("--output-dir") + 1] == "/data/runs"
93
+ assert "--force" in argv and "--actor" in argv
94
+ # Optionals stay OUT when unset (a copy-pasteable minimal command).
95
+ bare = batch_argv({"text_from": None, "graph_db_path": None, "manifests": ["a.json"]},
96
+ build_parser().parse_args([]), None)
97
+ for flag in ("--text-from", "--graph-db-path", "--sysmon-capability",
98
+ "--force", "--actor"):
99
+ assert flag not in bare
100
+
101
+
102
+ def test_parser_defaults():
103
+ args = build_parser().parse_args([])
104
+ # 5daadfc4: None sentinels — main() resolves the workspace's runs/ +
105
+ # .cjm/manifests when one is active, else the legacy cwd-relative defaults
106
+ assert args.runs_dir is None
107
+ assert args.manifests_dir is None
108
+ assert args.workspace is None
109
+ assert args.fa_capability == "cjm-capability-qwen3-forced-aligner"
110
+ assert args.graph_capability == "cjm-capability-graph-sqlite"
111
+ assert not args.plan_only and not args.no_sysmon and not args.force
112
+
113
+
114
+ def test_provenance_reads():
115
+ # e087d059: the graph db derives from the manifest's capabilities block.
116
+ m = {"capabilities": {"cjm-capability-graph-sqlite": {"db_path": "/data/g.db"},
117
+ "cjm-capability-whisper": {"db_path": None}},
118
+ "sources": [{"source_path": "/media/ep1.mp3", "segments": [{}]},
119
+ {"source_path": "/media/ep2.mp3", "segments": []}]}
120
+ assert SourceRunIndex.recorded_graph_db(m, "cjm-capability-graph-sqlite") == "/data/g.db"
121
+ assert SourceRunIndex.recorded_graph_db(m, "cjm-capability-other") is None
122
+ assert SourceRunIndex.recorded_graph_db({}, "cjm-capability-graph-sqlite") is None
123
+ # 614dd647: source identity paints straight off the manifest.
124
+ assert SourceRunIndex.source_names(m) == ["ep1", "ep2"]