personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

transcripts: stream attribution in pill tooltips (A10)

Day-timeline pills now show contributing streams in their hover title;
zoom-timeline pills name the segment's stream in title and aria-label.

Server-side: a private fold helper in apps/transcripts/routes.py converts
each (start, end) range into {start, end, streams} for the audio and
screen arrays returned from /api/ranges/<day> and /api/day/<day>. The
fold uses a half-open overlap rule and de-duplicates streams.
cluster_scan and scan_day signatures are unchanged.

Client-side: addSegmentIndicator takes a streams[] argument; the title
appends ` · {sorted streams joined by ` + `}` with `+N` overflow when
more than three contributors exist. Zoom-pill tooltip and aria-label
end with the segment's stream verbatim.

Baseline regenerated to the new object shape.

+215 -42
+43 -3
apps/transcripts/routes.py
··· 93 93 return stats 94 94 95 95 96 + def _attach_streams_to_ranges( 97 + ranges: list[tuple[str, str]], 98 + segments: list[dict[str, Any]], 99 + content_type: str, 100 + ) -> list[dict[str, Any]]: 101 + """Fold per-stream attribution into each (start, end) range. 102 + 103 + A segment contributes to a range when its half-open span overlaps the range 104 + and its types include ``content_type``. Streams are sorted and de-duped. 105 + """ 106 + 107 + def _to_min(hhmm: str) -> int: 108 + h, m = hhmm.split(":") 109 + return int(h) * 60 + int(m) 110 + 111 + out: list[dict[str, Any]] = [] 112 + for start, end in ranges: 113 + range_start = _to_min(start) 114 + range_end = _to_min(end) 115 + streams: set[str] = set() 116 + for seg in segments: 117 + if content_type not in seg.get("types", ()): 118 + continue 119 + seg_start = _to_min(seg["start"]) 120 + seg_end = _to_min(seg["end"]) 121 + if seg_start < range_end and seg_end > range_start: 122 + streams.add(seg["stream"]) 123 + out.append({"start": start, "end": end, "streams": sorted(streams)}) 124 + return out 125 + 126 + 96 127 @transcripts_bp.route("/") 97 128 def index() -> Any: 98 129 """Redirect to the most recent day with segments, falling back to today.""" ··· 120 151 if not DATE_RE.fullmatch(day): 121 152 return error_response("Day not found", 404) 122 153 123 - audio_ranges, screen_ranges = cluster_scan(day) 124 - return jsonify({"audio": audio_ranges, "screen": screen_ranges}) 154 + audio_ranges, screen_ranges, segments = scan_day(day) 155 + return jsonify( 156 + { 157 + "audio": _attach_streams_to_ranges(audio_ranges, segments, "audio"), 158 + "screen": _attach_streams_to_ranges(screen_ranges, segments, "screen"), 159 + } 160 + ) 125 161 126 162 127 163 @transcripts_bp.route("/api/segments/<day>") ··· 145 181 146 182 audio_ranges, screen_ranges, segments = scan_day(day) 147 183 return jsonify( 148 - {"audio": audio_ranges, "screen": screen_ranges, "segments": segments} 184 + { 185 + "audio": _attach_streams_to_ranges(audio_ranges, segments, "audio"), 186 + "screen": _attach_streams_to_ranges(screen_ranges, segments, "screen"), 187 + "segments": segments, 188 + } 149 189 ) 150 190 151 191
+107
apps/transcripts/tests/test_segment_routes.py
··· 8 8 9 9 import pytest 10 10 11 + from apps.transcripts.routes import _attach_streams_to_ranges 12 + 11 13 FIXTURE_DAY = "20260304" 12 14 FIXTURE_STREAM = "default" 13 15 FIXTURE_SEGMENT = "090000_300" 14 16 15 17 18 + def _write_segment( 19 + journal_root, 20 + day: str, 21 + stream: str, 22 + segment: str, 23 + *, 24 + audio: bool = True, 25 + screen: bool = True, 26 + ) -> None: 27 + segment_dir = journal_root / "chronicle" / day / stream / segment 28 + segment_dir.mkdir(parents=True, exist_ok=True) 29 + if audio: 30 + (segment_dir / "audio.jsonl").write_text("{}\n", encoding="utf-8") 31 + if screen: 32 + (segment_dir / "screen.jsonl").write_text( 33 + '{"raw": "screen.webm"}\n', 34 + encoding="utf-8", 35 + ) 36 + 37 + 16 38 def _action_log_rows(journal_root, day): 17 39 log_path = journal_root / "config" / "actions" / f"{day}.jsonl" 18 40 if not log_path.exists(): ··· 22 44 for line in log_path.read_text(encoding="utf-8").splitlines() 23 45 if line.strip() 24 46 ] 47 + 48 + 49 + def test_ranges_returns_object_shape_with_streams(client, journal_copy): 50 + day = "20990102" 51 + _write_segment(journal_copy, day, "alpha", "090000_300") 52 + _write_segment(journal_copy, day, "bravo", "090500_300") 53 + _write_segment(journal_copy, day, "alpha", "091000_300") 54 + 55 + response = client.get(f"/app/transcripts/api/ranges/{day}") 56 + 57 + assert response.status_code == 200 58 + data = response.get_json() 59 + assert set(data) == {"audio", "screen"} 60 + assert data["audio"] == [ 61 + {"start": "09:00", "end": "09:15", "streams": ["alpha", "bravo"]} 62 + ] 63 + assert data["screen"] == [ 64 + {"start": "09:00", "end": "09:15", "streams": ["alpha", "bravo"]} 65 + ] 66 + 67 + 68 + def test_ranges_overflow_returns_full_list(client, journal_copy): 69 + day = "20990103" 70 + for stream in ["echo", "alpha", "delta", "bravo", "charlie"]: 71 + _write_segment(journal_copy, day, stream, "090000_300", screen=False) 72 + 73 + response = client.get(f"/app/transcripts/api/ranges/{day}") 74 + 75 + assert response.status_code == 200 76 + assert response.get_json()["audio"] == [ 77 + { 78 + "start": "09:00", 79 + "end": "09:15", 80 + "streams": ["alpha", "bravo", "charlie", "delta", "echo"], 81 + } 82 + ] 83 + 84 + 85 + def test_ranges_single_stream(client, journal_copy): 86 + day = "20990104" 87 + _write_segment(journal_copy, day, "solo", "090000_300", screen=False) 88 + 89 + response = client.get(f"/app/transcripts/api/ranges/{day}") 90 + 91 + assert response.status_code == 200 92 + assert response.get_json()["audio"] == [ 93 + {"start": "09:00", "end": "09:15", "streams": ["solo"]} 94 + ] 95 + 96 + 97 + def test_day_returns_object_shape_with_streams(client, journal_copy): 98 + day = "20990105" 99 + _write_segment(journal_copy, day, "alpha", "090000_300") 100 + _write_segment(journal_copy, day, "bravo", "090500_300", screen=False) 101 + 102 + response = client.get(f"/app/transcripts/api/day/{day}") 103 + 104 + assert response.status_code == 200 105 + data = response.get_json() 106 + assert data["audio"] == [ 107 + {"start": "09:00", "end": "09:15", "streams": ["alpha", "bravo"]} 108 + ] 109 + assert data["screen"] == [{"start": "09:00", "end": "09:15", "streams": ["alpha"]}] 110 + assert data["segments"] == [ 111 + { 112 + "key": "090000_300", 113 + "start": "09:00", 114 + "end": "09:05", 115 + "types": ["audio", "screen"], 116 + "stream": "alpha", 117 + }, 118 + { 119 + "key": "090500_300", 120 + "start": "09:05", 121 + "end": "09:10", 122 + "types": ["audio"], 123 + "stream": "bravo", 124 + }, 125 + ] 126 + 127 + 128 + def test_attach_streams_to_ranges_empty_when_no_overlap(): 129 + result = _attach_streams_to_ranges([("09:00", "09:15")], [], "audio") 130 + 131 + assert result == [{"start": "09:00", "end": "09:15", "streams": []}] 25 132 26 133 27 134 @pytest.mark.parametrize("stream", ["-bad", "Upper", "..bad"])
+23 -15
apps/transcripts/workspace.html
··· 1999 1999 // Find min/max times across all ranges 2000 2000 let minTime = Infinity; 2001 2001 let maxTime = -Infinity; 2002 - for (const [start, end] of allRanges) { 2003 - const s = parseTime(start); 2004 - const e = parseTime(end); 2002 + for (const rg of allRanges) { 2003 + const s = parseTime(rg.start); 2004 + const e = parseTime(rg.end); 2005 2005 if (s < minTime) minTime = s; 2006 2006 if (e > maxTime) maxTime = e; 2007 2007 } ··· 2022 2022 return { start, end }; 2023 2023 } 2024 2024 2025 - function addSegmentIndicator(type, startMin, endMin, column) { 2025 + function addSegmentIndicator(type, startMin, endMin, column, streams = []) { 2026 2026 const el = document.createElement('div'); 2027 2027 el.className = 'tr-seg ' + (type === 'screen' ? 'tr-seg-screen' : 'tr-seg-audio'); 2028 2028 el.style.top = y(startMin) + 'px'; ··· 2035 2035 el.setAttribute('role', 'button'); 2036 2036 el.setAttribute('tabindex', '0'); 2037 2037 el.setAttribute('aria-label', _label); 2038 - el.title = hhmm(startMin) + ' – ' + hhmm(endMin) + ' (' + (endMin - startMin) + ' min, ' + type + ')'; 2038 + const sortedStreams = [...streams].sort(); 2039 + const streamHead = sortedStreams.slice(0, 3).join(' + '); 2040 + const streamToken = sortedStreams.length > 3 ? streamHead + ' +' + (sortedStreams.length - 3) : streamHead; 2041 + const streamSuffix = sortedStreams.length ? ' · ' + streamToken : ''; 2042 + el.title = hhmm(startMin) + ' – ' + hhmm(endMin) + ' (' + (endMin - startMin) + ' min, ' + type + streamSuffix + ')'; 2039 2043 el.addEventListener('click', e => { 2040 2044 e.stopPropagation(); 2041 2045 const midMin = (startMin + endMin) / 2; ··· 2191 2195 pill.style.height = Math.max(4, zoomY(visEnd) - zoomY(visStart)) + 'px'; 2192 2196 const duration = Math.round(visEnd - visStart); 2193 2197 const typeDesc = (hasAudio && hasScreen) ? 'audio + screen' : hasAudio ? 'audio' : 'screen'; 2194 - pill.title = seg.start + ' – ' + seg.end + ' · ' + duration + ' min · ' + typeDesc; 2195 - pill.setAttribute('aria-label', 'Segment ' + seg.start + ' \u2013 ' + seg.end + ', ' + typeLabel); 2198 + pill.title = seg.start + ' – ' + seg.end + ' · ' + duration + ' min · ' + typeDesc + ' · ' + seg.stream; 2199 + pill.setAttribute('aria-label', 'Segment ' + seg.start + ' \u2013 ' + seg.end + ', ' + typeLabel + ', ' + seg.stream); 2196 2200 pill.dataset.key = seg.key; 2197 2201 2198 2202 pill.addEventListener('click', () => selectSegment(seg)); ··· 3129 3133 3130 3134 // Add segment indicators from ranges 3131 3135 (data.audio || []).forEach(rg => { 3132 - const [s, e] = rg.map(parseTime); 3133 - addSegmentIndicator('audio', s, e, 0); 3136 + const s = parseTime(rg.start); 3137 + const e = parseTime(rg.end); 3138 + addSegmentIndicator('audio', s, e, 0, rg.streams); 3134 3139 }); 3135 3140 (data.screen || []).forEach(rg => { 3136 - const [s, e] = rg.map(parseTime); 3137 - addSegmentIndicator('screen', s, e, 1); 3141 + const s = parseTime(rg.start); 3142 + const e = parseTime(rg.end); 3143 + addSegmentIndicator('screen', s, e, 1, rg.streams); 3138 3144 }); 3139 3145 3140 3146 // Now-marker for today ··· 3381 3387 .then(data => { 3382 3388 segmentsLane.innerHTML = ''; 3383 3389 (data.audio || []).forEach(rg => { 3384 - const [s, e] = rg.map(parseTime); 3385 - addSegmentIndicator('audio', s, e, 0); 3390 + const s = parseTime(rg.start); 3391 + const e = parseTime(rg.end); 3392 + addSegmentIndicator('audio', s, e, 0, rg.streams); 3386 3393 }); 3387 3394 (data.screen || []).forEach(rg => { 3388 - const [s, e] = rg.map(parseTime); 3389 - addSegmentIndicator('screen', s, e, 1); 3395 + const s = parseTime(rg.start); 3396 + const e = parseTime(rg.end); 3397 + addSegmentIndicator('screen', s, e, 1, rg.streams); 3390 3398 }); 3391 3399 }) 3392 3400 .catch(() => {
+42 -24
tests/baselines/api/transcripts/ranges.json
··· 1 1 { 2 2 "audio": [ 3 - [ 4 - "09:00", 5 - "09:15" 6 - ], 7 - [ 8 - "14:00", 9 - "14:15" 10 - ], 11 - [ 12 - "18:00", 13 - "18:15" 14 - ] 3 + { 4 + "end": "09:15", 5 + "start": "09:00", 6 + "streams": [ 7 + "default" 8 + ] 9 + }, 10 + { 11 + "end": "14:15", 12 + "start": "14:00", 13 + "streams": [ 14 + "default" 15 + ] 16 + }, 17 + { 18 + "end": "18:15", 19 + "start": "18:00", 20 + "streams": [ 21 + "default" 22 + ] 23 + } 15 24 ], 16 25 "screen": [ 17 - [ 18 - "09:00", 19 - "09:15" 20 - ], 21 - [ 22 - "14:00", 23 - "14:15" 24 - ], 25 - [ 26 - "18:00", 27 - "18:15" 28 - ] 26 + { 27 + "end": "09:15", 28 + "start": "09:00", 29 + "streams": [ 30 + "default" 31 + ] 32 + }, 33 + { 34 + "end": "14:15", 35 + "start": "14:00", 36 + "streams": [ 37 + "default" 38 + ] 39 + }, 40 + { 41 + "end": "18:15", 42 + "start": "18:00", 43 + "streams": [ 44 + "default" 45 + ] 46 + } 29 47 ] 30 48 }