From bd145ef9920ebcec6756b41e92474caf3c32448a Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Fri, 29 May 2026 04:32:55 -0600 Subject: [PATCH] feat(transcripts): render sensed-but-not-thought segments as awaiting thinking The transcripts view derived each segment's visible state purely from the observe-layer modality state, so a segment that was transcribed but never thought rendered identically to a fully-processed one (false green). Add a think axis read from the think-layer fold (segment_fully_sensed + segment_fully_thought, keyed by segment key) and thread it through both the per-segment zoom pills and the server-side range rollup (worst-wins). A fully-sensed-but-not-thought segment now renders in a distinct "awaiting thinking" state. Read-only: the route helper annotates per-request response dicts only. Updates the transcripts API baselines to carry the new per-segment/range think field (20260304 has no health log, so all segments read "awaiting"). Co-Authored-By: Claude Opus 4.8 (1M context) --- solstone/apps/transcripts/routes.py | 40 ++++++- .../transcripts/tests/test_segment_routes.py | 107 +++++++++++++++++- .../tests/test_workspace_html_invariants.py | 12 ++ solstone/apps/transcripts/workspace.html | 59 ++++++++-- tests/baselines/api/transcripts/ranges.json | 18 ++- tests/baselines/api/transcripts/segments.json | 3 + 6 files changed, 222 insertions(+), 17 deletions(-) diff --git a/solstone/apps/transcripts/routes.py b/solstone/apps/transcripts/routes.py index cd160e9db..67231551b 100644 --- a/solstone/apps/transcripts/routes.py +++ b/solstone/apps/transcripts/routes.py @@ -59,6 +59,11 @@ from solstone.think.data_state import ( from solstone.think.entities.journal import get_journal_principal, load_journal_entity from solstone.think.media import MIME_TYPES from solstone.think.models import get_usage_cost +from solstone.think.pipeline_health import ( + read_segment_progress, + segment_fully_sensed, + segment_fully_thought, +) from solstone.think.supervisor import is_supervisor_up from solstone.think.utils import ( STREAM_RE, @@ -121,6 +126,24 @@ def _stats_for_month(month: str, mtime_key: float) -> dict[str, int]: return stats +def _attach_think_to_segments(segments: list[dict[str, Any]], day: str) -> None: + """Annotate each segment dict in place with a per-segment ``think`` verdict. + + Reads the day's think-layer progress fold once and applies the canonical + per-segment sense/think verdicts. Read-only: the segment dicts are freshly + built per request by cluster.scan_day/cluster_segments (no caching), and no + journal state is written. ``think`` is ``None`` until a segment is fully + sensed, then ``"awaiting"`` (sensed, not yet thought) or ``"thought"``. + """ + progress = read_segment_progress(day) + for seg in segments: + if not segment_fully_sensed(seg["data_state"]): + seg["think"] = None + continue + thought, _reason = segment_fully_thought(progress.get(seg["key"])) + seg["think"] = "thought" if thought else "awaiting" + + def _attach_streams_to_ranges( ranges: list[tuple[str, str]], segments: list[dict[str, Any]], @@ -143,6 +166,7 @@ def _attach_streams_to_ranges( range_end = _to_min(end) streams: set[str] = set() state = DataState.PENDING.value + think: str | None = None for seg in segments: if content_type not in seg.get("types", ()): continue @@ -150,6 +174,11 @@ def _attach_streams_to_ranges( seg_end = _to_min(seg["end"]) if seg_start < range_end and seg_end > range_start: streams.add(seg["stream"]) + seg_think = seg.get("think") + if seg_think == "awaiting": + think = "awaiting" + elif seg_think == "thought" and think != "awaiting": + think = "thought" modality_state = seg.get("data_state", {}).get(content_type) if modality_state == DataState.ANALYZED.value: state = DataState.ANALYZED.value @@ -159,7 +188,13 @@ def _attach_streams_to_ranges( ): state = DataState.ANALYZING.value out.append( - {"start": start, "end": end, "streams": sorted(streams), "state": state} + { + "start": start, + "end": end, + "streams": sorted(streams), + "state": state, + "think": think, + } ) return out @@ -192,6 +227,7 @@ def transcript_ranges(day: str) -> Any: return error_response(INVALID_DAY, status=404, detail="Day not found") audio_ranges, screen_ranges, segments = scan_day(day) + _attach_think_to_segments(segments, day) return jsonify( { "audio": _attach_streams_to_ranges(audio_ranges, segments, "audio"), @@ -210,6 +246,7 @@ def transcript_segments(day: str) -> Any: return error_response(INVALID_DAY, status=404, detail="Day not found") segments = cluster_segments(day) + _attach_think_to_segments(segments, day) return jsonify({"segments": segments}) @@ -220,6 +257,7 @@ def transcript_day_data(day: str) -> Any: return error_response(INVALID_DAY, status=404, detail="Day not found") audio_ranges, screen_ranges, segments = scan_day(day) + _attach_think_to_segments(segments, day) return jsonify( { "audio": _attach_streams_to_ranges(audio_ranges, segments, "audio"), diff --git a/solstone/apps/transcripts/tests/test_segment_routes.py b/solstone/apps/transcripts/tests/test_segment_routes.py index 5dadfd2ac..ea80ba4a1 100644 --- a/solstone/apps/transcripts/tests/test_segment_routes.py +++ b/solstone/apps/transcripts/tests/test_segment_routes.py @@ -82,6 +82,54 @@ def _write_jsonl(path, entries: list[dict]) -> None: ) +def _segment_event( + event: str, + segment: str, + name: str | None = None, + ts: int = 1, + **extra, +) -> dict: + record = {"event": event, "ts": ts, "mode": "segment", "segment": segment} + if name is not None: + record["name"] = name + record.update(extra) + return record + + +def _dispatch(segment: str, name: str, ts: int = 1) -> dict: + return _segment_event("talent.dispatch", segment, name, ts) + + +def _complete(segment: str, name: str, ts: int = 1) -> dict: + return _segment_event("talent.complete", segment, name, ts, state="finish") + + +def _fail(segment: str, name: str, ts: int = 1) -> dict: + return _segment_event("talent.fail", segment, name, ts, state="error") + + +def _sense_complete(segment: str, density: str = "active", ts: int = 1) -> dict: + return _segment_event("sense.complete", segment, ts=ts, density=density) + + +def _complete_segment_events(segment: str) -> list[dict]: + return [ + _dispatch(segment, "sense", 10), + _complete(segment, "sense", 11), + _sense_complete(segment, "active", 12), + _dispatch(segment, "entities", 13), + _complete(segment, "entities", 14), + _dispatch(segment, "documents", 15), + _complete(segment, "documents", 16), + ] + + +def _write_health(journal_root, day: str, filename: str, entries: list[dict]) -> None: + path = journal_root / "chronicle" / day / "health" / filename + path.parent.mkdir(parents=True, exist_ok=True) + _write_jsonl(path, entries) + + def _write_raw_pending_segment( journal_root, day: str, @@ -202,6 +250,7 @@ def test_ranges_returns_object_shape_with_streams(client, journal_copy): "end": "09:15", "streams": ["alpha", "bravo"], "state": "analyzed", + "think": "awaiting", } ] assert data["screen"] == [ @@ -210,6 +259,7 @@ def test_ranges_returns_object_shape_with_streams(client, journal_copy): "end": "09:15", "streams": ["alpha", "bravo"], "state": "analyzed", + "think": "awaiting", } ] @@ -228,6 +278,7 @@ def test_ranges_overflow_returns_full_list(client, journal_copy): "end": "09:15", "streams": ["alpha", "bravo", "charlie", "delta", "echo"], "state": "analyzed", + "think": "awaiting", } ] @@ -245,6 +296,7 @@ def test_ranges_single_stream(client, journal_copy): "end": "09:15", "streams": ["solo"], "state": "analyzed", + "think": "awaiting", } ] @@ -264,6 +316,7 @@ def test_day_returns_object_shape_with_streams(client, journal_copy): "end": "09:15", "streams": ["alpha", "bravo"], "state": "analyzed", + "think": "awaiting", } ] assert data["screen"] == [ @@ -272,6 +325,7 @@ def test_day_returns_object_shape_with_streams(client, journal_copy): "end": "09:15", "streams": ["alpha"], "state": "analyzed", + "think": "awaiting", } ] assert data["segments"] == [ @@ -282,6 +336,7 @@ def test_day_returns_object_shape_with_streams(client, journal_copy): "types": ["audio", "screen"], "stream": "alpha", "data_state": {"audio": "analyzed", "screen": "analyzed"}, + "think": "awaiting", }, { "key": "090500_300", @@ -290,6 +345,7 @@ def test_day_returns_object_shape_with_streams(client, journal_copy): "types": ["audio"], "stream": "bravo", "data_state": {"audio": "analyzed"}, + "think": "awaiting", }, ] @@ -298,10 +354,58 @@ def test_attach_streams_to_ranges_empty_when_no_overlap(): result = _attach_streams_to_ranges([("09:00", "09:15")], [], "audio") assert result == [ - {"start": "09:00", "end": "09:15", "streams": [], "state": "pending"} + { + "start": "09:00", + "end": "09:15", + "streams": [], + "state": "pending", + "think": None, + } ] +def test_routes_expose_sense_and_think_axes(client, journal_copy): + day = "20990116" + thought_segment = "090000_300" + awaiting_segment = "090500_300" + recovered_segment = "091000_300" + for segment in (thought_segment, awaiting_segment, recovered_segment): + _write_segment(journal_copy, day, "default", segment) + _write_health( + journal_copy, + day, + "001_segment.jsonl", + _complete_segment_events(thought_segment) + + [_sense_complete(awaiting_segment, "active", 30)] + + [ + _dispatch(recovered_segment, "sense", 40), + _complete(recovered_segment, "sense", 41), + _sense_complete(recovered_segment, "active", 42), + _dispatch(recovered_segment, "entities", 43), + _fail(recovered_segment, "entities", 44), + _complete(recovered_segment, "entities", 45), + _dispatch(recovered_segment, "documents", 46), + _complete(recovered_segment, "documents", 47), + ], + ) + + day_response = client.get(f"/app/transcripts/api/day/{day}") + ranges_response = client.get(f"/app/transcripts/api/ranges/{day}") + + assert day_response.status_code == 200 + segments = {seg["key"]: seg for seg in day_response.get_json()["segments"]} + assert segments[thought_segment]["think"] == "thought" + assert segments[awaiting_segment]["think"] == "awaiting" + assert segments[thought_segment]["think"] != segments[awaiting_segment]["think"] + assert segments[recovered_segment]["think"] == "thought" + + assert ranges_response.status_code == 200 + ranges = ranges_response.get_json() + assert ranges["audio"][0]["state"] == "analyzed" + assert ranges["audio"][0]["think"] == "awaiting" + assert ranges["screen"][0]["think"] == "awaiting" + + def test_ranges_best_state_wins_for_mixed_pending_and_analyzed(client, journal_copy): day = "20990109" _write_segment( @@ -330,6 +434,7 @@ def test_ranges_best_state_wins_for_mixed_pending_and_analyzed(client, journal_c "end": "09:15", "streams": ["default"], "state": "analyzed", + "think": "awaiting", } ] diff --git a/solstone/apps/transcripts/tests/test_workspace_html_invariants.py b/solstone/apps/transcripts/tests/test_workspace_html_invariants.py index f5e8105a8..4ba9d8f67 100644 --- a/solstone/apps/transcripts/tests/test_workspace_html_invariants.py +++ b/solstone/apps/transcripts/tests/test_workspace_html_invariants.py @@ -12,3 +12,15 @@ def test_workspace_html_single_purge_notice_emission(): assert text.count('
= 1 + assert "tr-seg-awaiting" in text + assert "tr-zoom-pill-awaiting" in text + assert "seg.think" in text + assert "rg.think" in text diff --git a/solstone/apps/transcripts/workspace.html b/solstone/apps/transcripts/workspace.html index 9d92554e3..3c0db23e2 100644 --- a/solstone/apps/transcripts/workspace.html +++ b/solstone/apps/transcripts/workspace.html @@ -281,6 +281,21 @@ body.has-app-bar .tr-card { border-color: #ca8a04; } +.tr-seg-awaiting { + opacity: 1; + border-style: solid; +} + +.tr-seg-audio.tr-seg-awaiting { + background: rgba(245, 158, 11, 0.58); + border-color: #b45309; +} + +.tr-seg-screen.tr-seg-awaiting { + background: rgba(251, 191, 36, 0.58); + border-color: #b45309; +} + .tr-seg-analyzing { opacity: 1; border-style: solid; @@ -725,6 +740,25 @@ body.has-app-bar .tr-zoom { border-color: #65a30d; } +.tr-zoom-pill-awaiting { + border-style: solid; +} + +.tr-zoom-pill-audio.tr-zoom-pill-awaiting { + background: rgba(245, 158, 11, 0.62); + border-color: #b45309; +} + +.tr-zoom-pill-screen.tr-zoom-pill-awaiting { + background: rgba(251, 191, 36, 0.62); + border-color: #b45309; +} + +.tr-zoom-pill-both.tr-zoom-pill-awaiting { + background: linear-gradient(to right, rgba(245, 158, 11, 0.64), rgba(251, 191, 36, 0.64)); + border-color: #b45309; +} + .tr-zoom-pill-analyzing { border-style: solid; animation: tr-analyzing-pulse 1.5s ease-in-out infinite; @@ -1656,6 +1690,7 @@ body.presentation-mode .tr-screen-text { font-size: 16px; padding: 12px 16px; bo const BTN_LABEL_PENDING = 'analyze now'; const BTN_LABEL_FAILED = 'try again'; const ANALYZING_LABEL = 'analyzing…'; + const AWAITING_THINKING_LABEL = 'awaiting thinking'; const BTN_TITLE = modality => `Process this segment's raw ${modality} observed data and generate analyzed content.`; const ANALYZE_POLL_INTERVAL_MS = 3000; const ANALYZE_POLL_MAX_MS = 30 * 60 * 1000; @@ -2248,13 +2283,15 @@ body.presentation-mode .tr-screen-text { font-size: 16px; padding: 12px 16px; bo return { start, end }; } - function addSegmentIndicator(type, startMin, endMin, column, streams = [], state = 'analyzed') { + function addSegmentIndicator(type, startMin, endMin, column, streams = [], state = 'analyzed', think = null) { const el = document.createElement('div'); el.className = 'tr-seg ' + (type === 'screen' ? 'tr-seg-screen' : 'tr-seg-audio'); if (state === 'pending') { el.classList.add('tr-seg-pending'); } else if (state === 'analyzing') { el.classList.add('tr-seg-analyzing'); + } else if (think === 'awaiting') { + el.classList.add('tr-seg-awaiting'); } el.style.top = y(startMin) + 'px'; el.style.height = Math.max(2, y(endMin) - y(startMin)) + 'px'; @@ -2270,7 +2307,7 @@ body.presentation-mode .tr-screen-text { font-size: 16px; padding: 12px 16px; bo const streamHead = sortedStreams.slice(0, 3).join(' + '); const streamToken = sortedStreams.length > 3 ? streamHead + ' +' + (sortedStreams.length - 3) : streamHead; const streamSuffix = sortedStreams.length ? ' · ' + streamToken : ''; - const stateSuffix = state === 'pending' ? ' · pending' : state === 'analyzing' ? ' · analyzing' : ''; + const stateSuffix = state === 'pending' ? ' · pending' : state === 'analyzing' ? ' · analyzing' : (think === 'awaiting' ? ' · ' + AWAITING_THINKING_LABEL : ''); el.title = hhmm(startMin) + ' – ' + hhmm(endMin) + ' (' + (endMin - startMin) + ' min, ' + type + streamSuffix + stateSuffix + ')'; el.addEventListener('click', e => { e.stopPropagation(); @@ -2426,6 +2463,8 @@ body.presentation-mode .tr-screen-text { font-size: 16px; padding: 12px 16px; bo pill.classList.add('tr-zoom-pill-analyzing'); } else if (hasPendingAdvertised) { pill.classList.add('tr-zoom-pill-pending'); + } else if (seg.think === 'awaiting') { + pill.classList.add('tr-zoom-pill-awaiting'); } const typeLabel = (hasAudio && hasScreen) ? 'audio and screen' : hasAudio ? 'audio' : 'screen'; @@ -2437,9 +2476,11 @@ body.presentation-mode .tr-screen-text { font-size: 16px; padding: 12px 16px; bo pill.style.height = Math.max(4, zoomY(visEnd) - zoomY(visStart)) + 'px'; const duration = Math.round(visEnd - visStart); const typeDesc = (hasAudio && hasScreen) ? 'audio + screen' : hasAudio ? 'audio' : 'screen'; - const pendingSuffix = hasAnalyzingAdvertised ? ' · analyzing' : hasPendingAdvertised ? ' · pending' : ''; - pill.title = seg.start + ' – ' + seg.end + ' · ' + duration + ' min · ' + typeDesc + ' · ' + seg.stream + pendingSuffix; - pill.setAttribute('aria-label', 'Segment ' + seg.start + ' \u2013 ' + seg.end + ', ' + typeLabel + ', ' + seg.stream); + const segmentStatusLabel = hasAnalyzingAdvertised ? 'analyzing' : hasPendingAdvertised ? 'pending' : seg.think === 'awaiting' ? AWAITING_THINKING_LABEL : ''; + const statusSuffix = segmentStatusLabel ? ' · ' + segmentStatusLabel : ''; + const ariaStatusSuffix = segmentStatusLabel ? ', ' + segmentStatusLabel : ''; + pill.title = seg.start + ' – ' + seg.end + ' · ' + duration + ' min · ' + typeDesc + ' · ' + seg.stream + statusSuffix; + pill.setAttribute('aria-label', 'Segment ' + seg.start + ' \u2013 ' + seg.end + ', ' + typeLabel + ', ' + seg.stream + ariaStatusSuffix); pill.dataset.key = seg.key; pill.addEventListener('click', () => selectSegment(seg)); @@ -3624,12 +3665,12 @@ body.presentation-mode .tr-screen-text { font-size: 16px; padding: 12px 16px; bo (data.audio || []).forEach(rg => { const s = parseTime(rg.start); const e = parseTime(rg.end); - addSegmentIndicator('audio', s, e, 0, rg.streams, rg.state || 'analyzed'); + addSegmentIndicator('audio', s, e, 0, rg.streams, rg.state || 'analyzed', rg.think); }); (data.screen || []).forEach(rg => { const s = parseTime(rg.start); const e = parseTime(rg.end); - addSegmentIndicator('screen', s, e, 1, rg.streams, rg.state || 'analyzed'); + addSegmentIndicator('screen', s, e, 1, rg.streams, rg.state || 'analyzed', rg.think); }); // Now-marker for today @@ -3916,12 +3957,12 @@ body.presentation-mode .tr-screen-text { font-size: 16px; padding: 12px 16px; bo (data.audio || []).forEach(rg => { const s = parseTime(rg.start); const e = parseTime(rg.end); - addSegmentIndicator('audio', s, e, 0, rg.streams, rg.state || 'analyzed'); + addSegmentIndicator('audio', s, e, 0, rg.streams, rg.state || 'analyzed', rg.think); }); (data.screen || []).forEach(rg => { const s = parseTime(rg.start); const e = parseTime(rg.end); - addSegmentIndicator('screen', s, e, 1, rg.streams, rg.state || 'analyzed'); + addSegmentIndicator('screen', s, e, 1, rg.streams, rg.state || 'analyzed', rg.think); }); }) .catch(() => { diff --git a/tests/baselines/api/transcripts/ranges.json b/tests/baselines/api/transcripts/ranges.json index 7b5bd9a15..b98ae35ba 100644 --- a/tests/baselines/api/transcripts/ranges.json +++ b/tests/baselines/api/transcripts/ranges.json @@ -6,7 +6,8 @@ "state": "analyzed", "streams": [ "default" - ] + ], + "think": "awaiting" }, { "end": "14:15", @@ -14,7 +15,8 @@ "state": "analyzed", "streams": [ "default" - ] + ], + "think": "awaiting" }, { "end": "18:15", @@ -22,7 +24,8 @@ "state": "analyzed", "streams": [ "default" - ] + ], + "think": "awaiting" } ], "screen": [ @@ -32,7 +35,8 @@ "state": "analyzed", "streams": [ "default" - ] + ], + "think": "awaiting" }, { "end": "14:15", @@ -40,7 +44,8 @@ "state": "analyzed", "streams": [ "default" - ] + ], + "think": "awaiting" }, { "end": "18:15", @@ -48,7 +53,8 @@ "state": "analyzed", "streams": [ "default" - ] + ], + "think": "awaiting" } ] } diff --git a/tests/baselines/api/transcripts/segments.json b/tests/baselines/api/transcripts/segments.json index 3cd9a816c..7dc98475f 100644 --- a/tests/baselines/api/transcripts/segments.json +++ b/tests/baselines/api/transcripts/segments.json @@ -9,6 +9,7 @@ "key": "090000_300", "start": "09:00", "stream": "default", + "think": "awaiting", "types": [ "audio", "screen" @@ -23,6 +24,7 @@ "key": "140000_300", "start": "14:00", "stream": "default", + "think": "awaiting", "types": [ "audio", "screen" @@ -37,6 +39,7 @@ "key": "180000_300", "start": "18:00", "stream": "default", + "think": "awaiting", "types": [ "audio", "screen" -- 2.51.2