personal memory agent
0
fork

Configure Feed

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

Surface high-null segments for voiceprint seeding

Add attribution_null and attribution_non_owner_total to segment API,
sort segment sidebar by null proportion when "Needs review" is active,
show voiceprint seeding prompt for high-null segments with named speakers,
and prioritize segments with speakers.json in the suggest command.

+112 -61
+1 -1
apps/speakers/owner.py
··· 373 373 374 374 Returns a dict with status and principal_id on success, or an error key. 375 375 """ 376 + from think.entities import entity_slug 376 377 from think.entities.core import get_identity_names 377 378 from think.entities.journal import ( 378 379 ensure_journal_entity_memory, 379 380 get_or_create_journal_entity, 380 381 ) 381 - from think.entities import entity_slug 382 382 383 383 candidate_path = _owner_candidate_path() 384 384 if not candidate_path.exists():
+16 -9
apps/speakers/routes.py
··· 13 13 import logging 14 14 import os 15 15 import re 16 - from datetime import date, datetime 16 + from datetime import date 17 17 from pathlib import Path 18 18 from typing import Any 19 19 ··· 39 39 from apps.utils import log_app_action 40 40 from convey import state 41 41 from convey.utils import DATE_RE, error_response, format_date, success_response 42 - from think.awareness import get_current, update_state 43 - from think.entities import ( 44 - entity_slug, 45 - find_matching_entity, 46 - ) 47 - from think.entities.core import get_identity_names 42 + from think.awareness import get_current 43 + from think.entities import find_matching_entity 48 44 from think.entities.journal import ( 49 45 ensure_journal_entity_memory, 50 46 get_journal_principal, 51 - get_or_create_journal_entity, 52 47 journal_entity_memory_path, 53 48 load_all_journal_entities, 54 49 load_journal_entity, ··· 56 51 from think.utils import ( 57 52 day_dirs, 58 53 day_path, 59 - get_journal, 60 54 iter_segments, 61 55 now_ms, 62 56 segment_parse, ··· 594 588 return error_response("Invalid day format", 400) 595 589 596 590 segments = _scan_segment_embeddings(day) 591 + principal = get_journal_principal() 592 + principal_id = principal["id"] if principal else None 597 593 for seg in segments: 598 594 seg_dir = get_segment_path(day, seg["key"], seg["stream"]) 599 595 labels_data = _load_speaker_labels(seg_dir) ··· 605 601 for label in labels 606 602 if label.get("confidence") == "medium" or not label.get("speaker") 607 603 ) 604 + seg["attribution_null"] = sum( 605 + 1 for label in labels if not label.get("speaker") 606 + ) 607 + owner_count = sum( 608 + 1 609 + for label in labels 610 + if label.get("speaker") and label.get("speaker") == principal_id 611 + ) 612 + seg["attribution_non_owner_total"] = len(labels) - owner_count 608 613 else: 609 614 seg["attribution_total"] = 0 610 615 seg["attribution_needs_review"] = 0 616 + seg["attribution_null"] = 0 617 + seg["attribution_non_owner_total"] = 0 611 618 612 619 return jsonify({"segments": segments}) 613 620
+32 -18
apps/speakers/suggest.py
··· 292 292 293 293 294 294 def _low_confidence_review() -> list[dict[str, Any]]: 295 - day_totals: dict[str, dict[str, int]] = {} 295 + results: list[dict[str, Any]] = [] 296 296 297 297 for day in sorted(day_dirs().keys()): 298 - for _stream, _segment_key, seg_path in iter_segments(day): 298 + for stream, segment_key, seg_path in iter_segments(day): 299 299 labels_path = seg_path / "agents" / "speaker_labels.json" 300 300 if not labels_path.exists(): 301 301 continue ··· 308 308 if not isinstance(labels, list): 309 309 continue 310 310 311 - counts = day_totals.setdefault(day, {"medium_or_null": 0, "total": 0}) 311 + medium_or_null = 0 312 + null_count = 0 313 + total = 0 312 314 for label in labels: 313 315 if not isinstance(label, dict): 314 316 continue 315 - counts["total"] += 1 317 + total += 1 316 318 if label.get("confidence") != "high": 317 - counts["medium_or_null"] += 1 319 + medium_or_null += 1 320 + if not label.get("speaker"): 321 + null_count += 1 322 + 323 + if medium_or_null <= 10: 324 + continue 325 + 326 + speakers_path = seg_path / "agents" / "speakers.json" 327 + has_speakers = speakers_path.is_file() 328 + null_proportion = null_count / total if total else 0.0 329 + results.append( 330 + { 331 + "type": "low_confidence_review", 332 + "day": day, 333 + "segment_key": segment_key, 334 + "stream": stream, 335 + "medium_or_null_count": medium_or_null, 336 + "total_labels": total, 337 + "has_speakers": has_speakers, 338 + "null_proportion": null_proportion, 339 + } 340 + ) 318 341 319 - suggestions = [ 320 - { 321 - "type": "low_confidence_review", 322 - "day": day, 323 - "medium_or_null_count": counts["medium_or_null"], 324 - "total_labels": counts["total"], 325 - } 326 - for day, counts in day_totals.items() 327 - if counts["medium_or_null"] > 10 328 - ] 329 - suggestions.sort(key=lambda item: item["medium_or_null_count"], reverse=True) 330 - return suggestions 342 + results.sort(key=lambda item: (not item["has_speakers"], -item["null_proportion"])) 343 + return results 331 344 332 345 333 346 def suggest_opportunities(limit: int = 5) -> list[dict[str, Any]]: ··· 383 396 f"(similarity: {suggestion['similarity']:.2f})" 384 397 ) 385 398 elif suggestion_type == "low_confidence_review": 399 + seg_info = suggestion.get("segment_key", "") 386 400 lines.append( 387 401 "Low confidence review: " 388 - f"{suggestion['day']} \u2014 " 402 + f"{suggestion['day']}/{seg_info} \u2014 " 389 403 f"{suggestion['medium_or_null_count']} of " 390 404 f"{suggestion['total_labels']} labels are medium/unresolved" 391 405 )
+20 -31
apps/speakers/tests/test_suggest.py
··· 55 55 56 56 def test_suggest_low_confidence_review(speakers_env): 57 57 env = speakers_env() 58 - for idx in range(4): 58 + for idx in range(2): 59 59 segment_key = f"1000{idx:02d}_300" 60 60 env.create_segment("20240101", segment_key, ["mic_audio"]) 61 - env.create_speaker_labels( 62 - "20240101", 63 - segment_key, 64 - [ 61 + labels = [] 62 + for sid in range(1, 13): 63 + labels.append( 65 64 { 66 - "sentence_id": 1, 67 - "speaker": "alice_test", 68 - "confidence": "medium", 69 - "method": "voiceprint", 70 - }, 71 - { 72 - "sentence_id": 2, 73 - "speaker": None, 74 - "confidence": None, 75 - "method": None, 76 - }, 77 - { 78 - "sentence_id": 3, 79 - "speaker": "alice_test", 80 - "confidence": "medium", 81 - "method": "voiceprint", 82 - }, 83 - ], 84 - ) 65 + "sentence_id": sid, 66 + "speaker": "alice_test" if sid % 2 == 0 else None, 67 + "confidence": "medium" if sid % 2 == 0 else None, 68 + "method": "voiceprint" if sid % 2 == 0 else None, 69 + } 70 + ) 71 + env.create_speaker_labels("20240101", segment_key, labels) 85 72 86 73 results = suggest_opportunities() 87 74 88 - suggestion = next( 89 - item for item in results if item["type"] == "low_confidence_review" 90 - ) 91 - assert suggestion["day"] == "20240101" 92 - assert suggestion["medium_or_null_count"] == 12 93 - assert suggestion["total_labels"] == 12 75 + low_conf = [item for item in results if item["type"] == "low_confidence_review"] 76 + assert len(low_conf) == 2 77 + for suggestion in low_conf: 78 + assert suggestion["day"] == "20240101" 79 + assert suggestion["medium_or_null_count"] == 12 80 + assert suggestion["total_labels"] == 12 81 + assert "segment_key" in suggestion 82 + assert "null_proportion" in suggestion 94 83 95 84 96 85 def test_suggest_low_confidence_below_threshold(speakers_env): ··· 238 227 "confidence": None, 239 228 "method": None, 240 229 } 241 - for sid in range(1, 4) 230 + for sid in range(1, 13) 242 231 ], 243 232 ) 244 233
+30 -1
apps/speakers/workspace.html
··· 197 197 flex-wrap: wrap; 198 198 } 199 199 200 + .spk-seeding-prompt { 201 + padding: 12px; 202 + margin-bottom: 12px; 203 + background: var(--bg-info, #e8f4fd); 204 + border: 1px solid var(--border-info, #b3d9f2); 205 + border-radius: 6px; 206 + font-size: 13px; 207 + line-height: 1.5; 208 + color: var(--text-primary, #333); 209 + } 210 + 200 211 .spk-filter-btn { 201 212 padding: 6px 10px; 202 213 border: 1px solid #d1d5db; ··· 888 899 return; 889 900 } 890 901 891 - segmentList.innerHTML = segments.map(seg => ` 902 + // Sort segments: by null proportion when needs_review, otherwise by key (chronological) 903 + const sorted = [...segments].sort((a, b) => { 904 + if (currentFilter === 'needs_review') { 905 + const aProp = a.attribution_total ? a.attribution_null / a.attribution_total : 0; 906 + const bProp = b.attribution_total ? b.attribution_null / b.attribution_total : 0; 907 + if (bProp !== aProp) return bProp - aProp; 908 + } 909 + return a.key.localeCompare(b.key); 910 + }); 911 + 912 + segmentList.innerHTML = sorted.map(seg => ` 892 913 <div class="spk-segment${selectedSegment?.key === seg.key ? ' active' : ''}" data-key="${seg.key}"> 893 914 <div class="spk-segment-time"> 894 915 ${seg.start} - ${seg.end} ··· 1104 1125 <button class="spk-filter-btn${currentFilter === 'needs_review' ? ' active' : ''}" data-filter="needs_review">Needs Review</button> 1105 1126 <button class="spk-filter-btn${currentFilter === 'corrections' ? ' active' : ''}" data-filter="corrections">Corrections</button> 1106 1127 </div> 1128 + ${(() => { 1129 + const seg = selectedSegment; 1130 + const nonOwnerTotal = seg?.attribution_non_owner_total || 0; 1131 + const nullCount = seg?.attribution_null || 0; 1132 + const showPrompt = seg?.speaker_count > 0 && nonOwnerTotal > 0 && (nullCount / nonOwnerTotal) > 0.5; 1133 + return showPrompt ? `<div class="spk-seeding-prompt">This meeting has ${seg.speaker_count} named speakers but I can't match voices yet. Assigning even a few sentences will help me learn their voices for future meetings.</div>` : ''; 1134 + })()} 1107 1135 ${!hasLabels ? '<div class="spk-status spk-status-error">Not yet attributed</div>' : ''} 1108 1136 ${sentences.length === 0 ? '<div class="spk-empty">No sentences match this filter</div>' : sentences.map(renderSentence).join('')} 1109 1137 `; ··· 1112 1140 btn.addEventListener('click', () => { 1113 1141 currentFilter = btn.dataset.filter; 1114 1142 renderReviewList(); 1143 + renderSegmentList(); 1115 1144 }); 1116 1145 }); 1117 1146
+12 -1
tests/baselines/api/speakers/speakers-segment.json
··· 1 1 { 2 - "matched": [], 2 + "matched": [ 3 + { 4 + "detected_name": "Juliet Capulet", 5 + "entity_name": "Juliet Capulet", 6 + "entity_type": "Person" 7 + }, 8 + { 9 + "detected_name": "Mercutio", 10 + "entity_name": "Mercutio Escalus", 11 + "entity_type": "Person" 12 + } 13 + ], 3 14 "unmatched": [] 4 15 }
+1
tests/fixtures/journal/20260304/default/090000_300/agents/speakers.json
··· 1 + ["Juliet Capulet", "Mercutio"]