cjm-transcript-decomp-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_decomp_tui/__init__.py +1 -0
- cjm_transcript_decomp_tui/app.py +487 -0
- cjm_transcript_decomp_tui/cli.py +142 -0
- cjm_transcript_decomp_tui/discovery.py +55 -0
- cjm_transcript_decomp_tui/runs.py +174 -0
- cjm_transcript_decomp_tui/state.py +35 -0
- cjm_transcript_decomp_tui-0.0.1.dist-info/METADATA +62 -0
- cjm_transcript_decomp_tui-0.0.1.dist-info/RECORD +11 -0
- cjm_transcript_decomp_tui-0.0.1.dist-info/WHEEL +5 -0
- cjm_transcript_decomp_tui-0.0.1.dist-info/entry_points.txt +2 -0
- cjm_transcript_decomp_tui-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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,11 @@
|
|
|
1
|
+
cjm_transcript_decomp_tui/__init__.py,sha256=sXLh7g3KC4QCFxcZGBTpG2scR7hmmBsMjq6LqRptkRg,22
|
|
2
|
+
cjm_transcript_decomp_tui/app.py,sha256=ChlobuDFVUMELAyFBc4u70IQ-HaQHJ-pkl7GqMNrf_A,22615
|
|
3
|
+
cjm_transcript_decomp_tui/cli.py,sha256=QbqCin8rXJBPMTd02X664YA8SyhT9CYC7EjZCIaOSg0,7522
|
|
4
|
+
cjm_transcript_decomp_tui/discovery.py,sha256=wIZaLGWwseUy55Srk6Lnd_VPkyF_ZnWTDVHmetHhgmM,2660
|
|
5
|
+
cjm_transcript_decomp_tui/runs.py,sha256=dSUNJWnQv1Se8Wcbv5GrXmOqW2gLMmxT8HwlmP-biHE,8354
|
|
6
|
+
cjm_transcript_decomp_tui/state.py,sha256=V_-y3AzNo_u_Qz-w2z8c2PqTe3NV2VaetVrnzC38lxU,1488
|
|
7
|
+
cjm_transcript_decomp_tui-0.0.1.dist-info/METADATA,sha256=yCzu6e7m5P5sNqAFh6olH3qDk7Ik0alW9m5uhLNzW1w,3188
|
|
8
|
+
cjm_transcript_decomp_tui-0.0.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
cjm_transcript_decomp_tui-0.0.1.dist-info/entry_points.txt,sha256=iQaZ--Z_KSaz3KbiaA83tHNz8BkIEzpiLGwP-Fo-Fm8,81
|
|
10
|
+
cjm_transcript_decomp_tui-0.0.1.dist-info/top_level.txt,sha256=hYgNb0JTBjPC1hakYL1M__PDNHM85kOaTyNpw2GizWA,26
|
|
11
|
+
cjm_transcript_decomp_tui-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cjm_transcript_decomp_tui
|