From 0098ed3c9b42df02f95cfe3bed6fd05d8e5b2b59 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Thu, 19 Mar 2026 11:04:10 -0600 Subject: [PATCH] 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. --- apps/speakers/owner.py | 2 +- apps/speakers/routes.py | 25 +++++---- apps/speakers/suggest.py | 52 ++++++++++++------- apps/speakers/tests/test_suggest.py | 51 +++++++----------- apps/speakers/workspace.html | 31 ++++++++++- .../api/speakers/speakers-segment.json | 13 ++++- .../default/090000_300/agents/speakers.json | 1 + 7 files changed, 113 insertions(+), 62 deletions(-) create mode 100644 tests/fixtures/journal/20260304/default/090000_300/agents/speakers.json diff --git a/apps/speakers/owner.py b/apps/speakers/owner.py index 5e0a6a022..24e4761e3 100644 --- a/apps/speakers/owner.py +++ b/apps/speakers/owner.py @@ -373,12 +373,12 @@ def confirm_owner_candidate() -> dict[str, Any]: Returns a dict with status and principal_id on success, or an error key. """ + from think.entities import entity_slug from think.entities.core import get_identity_names from think.entities.journal import ( ensure_journal_entity_memory, get_or_create_journal_entity, ) - from think.entities import entity_slug candidate_path = _owner_candidate_path() if not candidate_path.exists(): diff --git a/apps/speakers/routes.py b/apps/speakers/routes.py index 4fb1d75de..afaac688b 100644 --- a/apps/speakers/routes.py +++ b/apps/speakers/routes.py @@ -13,7 +13,7 @@ import json import logging import os import re -from datetime import date, datetime +from datetime import date from pathlib import Path from typing import Any @@ -39,16 +39,11 @@ from apps.speakers.owner import ( from apps.utils import log_app_action from convey import state from convey.utils import DATE_RE, error_response, format_date, success_response -from think.awareness import get_current, update_state -from think.entities import ( - entity_slug, - find_matching_entity, -) -from think.entities.core import get_identity_names +from think.awareness import get_current +from think.entities import find_matching_entity from think.entities.journal import ( ensure_journal_entity_memory, get_journal_principal, - get_or_create_journal_entity, journal_entity_memory_path, load_all_journal_entities, load_journal_entity, @@ -56,7 +51,6 @@ from think.entities.journal import ( from think.utils import ( day_dirs, day_path, - get_journal, iter_segments, now_ms, segment_parse, @@ -594,6 +588,8 @@ def api_segments(day: str) -> Any: return error_response("Invalid day format", 400) segments = _scan_segment_embeddings(day) + principal = get_journal_principal() + principal_id = principal["id"] if principal else None for seg in segments: seg_dir = get_segment_path(day, seg["key"], seg["stream"]) labels_data = _load_speaker_labels(seg_dir) @@ -605,9 +601,20 @@ def api_segments(day: str) -> Any: for label in labels if label.get("confidence") == "medium" or not label.get("speaker") ) + seg["attribution_null"] = sum( + 1 for label in labels if not label.get("speaker") + ) + owner_count = sum( + 1 + for label in labels + if label.get("speaker") and label.get("speaker") == principal_id + ) + seg["attribution_non_owner_total"] = len(labels) - owner_count else: seg["attribution_total"] = 0 seg["attribution_needs_review"] = 0 + seg["attribution_null"] = 0 + seg["attribution_non_owner_total"] = 0 return jsonify({"segments": segments}) diff --git a/apps/speakers/suggest.py b/apps/speakers/suggest.py index 052614eb5..3a519019a 100644 --- a/apps/speakers/suggest.py +++ b/apps/speakers/suggest.py @@ -292,10 +292,10 @@ def _name_variant() -> list[dict[str, Any]]: def _low_confidence_review() -> list[dict[str, Any]]: - day_totals: dict[str, dict[str, int]] = {} + results: list[dict[str, Any]] = [] for day in sorted(day_dirs().keys()): - for _stream, _segment_key, seg_path in iter_segments(day): + for stream, segment_key, seg_path in iter_segments(day): labels_path = seg_path / "agents" / "speaker_labels.json" if not labels_path.exists(): continue @@ -308,26 +308,39 @@ def _low_confidence_review() -> list[dict[str, Any]]: if not isinstance(labels, list): continue - counts = day_totals.setdefault(day, {"medium_or_null": 0, "total": 0}) + medium_or_null = 0 + null_count = 0 + total = 0 for label in labels: if not isinstance(label, dict): continue - counts["total"] += 1 + total += 1 if label.get("confidence") != "high": - counts["medium_or_null"] += 1 - - suggestions = [ - { - "type": "low_confidence_review", - "day": day, - "medium_or_null_count": counts["medium_or_null"], - "total_labels": counts["total"], - } - for day, counts in day_totals.items() - if counts["medium_or_null"] > 10 - ] - suggestions.sort(key=lambda item: item["medium_or_null_count"], reverse=True) - return suggestions + medium_or_null += 1 + if not label.get("speaker"): + null_count += 1 + + if medium_or_null <= 10: + continue + + speakers_path = seg_path / "agents" / "speakers.json" + has_speakers = speakers_path.is_file() + null_proportion = null_count / total if total else 0.0 + results.append( + { + "type": "low_confidence_review", + "day": day, + "segment_key": segment_key, + "stream": stream, + "medium_or_null_count": medium_or_null, + "total_labels": total, + "has_speakers": has_speakers, + "null_proportion": null_proportion, + } + ) + + results.sort(key=lambda item: (not item["has_speakers"], -item["null_proportion"])) + return results def suggest_opportunities(limit: int = 5) -> list[dict[str, Any]]: @@ -383,9 +396,10 @@ def format_suggestions(suggestions: list[dict[str, Any]]) -> str: f"(similarity: {suggestion['similarity']:.2f})" ) elif suggestion_type == "low_confidence_review": + seg_info = suggestion.get("segment_key", "") lines.append( "Low confidence review: " - f"{suggestion['day']} \u2014 " + f"{suggestion['day']}/{seg_info} \u2014 " f"{suggestion['medium_or_null_count']} of " f"{suggestion['total_labels']} labels are medium/unresolved" ) diff --git a/apps/speakers/tests/test_suggest.py b/apps/speakers/tests/test_suggest.py index ea5571043..2ecfd067c 100644 --- a/apps/speakers/tests/test_suggest.py +++ b/apps/speakers/tests/test_suggest.py @@ -55,42 +55,31 @@ def test_suggest_empty_journal(speakers_env): def test_suggest_low_confidence_review(speakers_env): env = speakers_env() - for idx in range(4): + for idx in range(2): segment_key = f"1000{idx:02d}_300" env.create_segment("20240101", segment_key, ["mic_audio"]) - env.create_speaker_labels( - "20240101", - segment_key, - [ + labels = [] + for sid in range(1, 13): + labels.append( { - "sentence_id": 1, - "speaker": "alice_test", - "confidence": "medium", - "method": "voiceprint", - }, - { - "sentence_id": 2, - "speaker": None, - "confidence": None, - "method": None, - }, - { - "sentence_id": 3, - "speaker": "alice_test", - "confidence": "medium", - "method": "voiceprint", - }, - ], - ) + "sentence_id": sid, + "speaker": "alice_test" if sid % 2 == 0 else None, + "confidence": "medium" if sid % 2 == 0 else None, + "method": "voiceprint" if sid % 2 == 0 else None, + } + ) + env.create_speaker_labels("20240101", segment_key, labels) results = suggest_opportunities() - suggestion = next( - item for item in results if item["type"] == "low_confidence_review" - ) - assert suggestion["day"] == "20240101" - assert suggestion["medium_or_null_count"] == 12 - assert suggestion["total_labels"] == 12 + low_conf = [item for item in results if item["type"] == "low_confidence_review"] + assert len(low_conf) == 2 + for suggestion in low_conf: + assert suggestion["day"] == "20240101" + assert suggestion["medium_or_null_count"] == 12 + assert suggestion["total_labels"] == 12 + assert "segment_key" in suggestion + assert "null_proportion" in suggestion def test_suggest_low_confidence_below_threshold(speakers_env): @@ -238,7 +227,7 @@ def test_suggest_priority_order(speakers_env): "confidence": None, "method": None, } - for sid in range(1, 4) + for sid in range(1, 13) ], ) diff --git a/apps/speakers/workspace.html b/apps/speakers/workspace.html index 6017b7b90..777608634 100644 --- a/apps/speakers/workspace.html +++ b/apps/speakers/workspace.html @@ -197,6 +197,17 @@ flex-wrap: wrap; } +.spk-seeding-prompt { + padding: 12px; + margin-bottom: 12px; + background: var(--bg-info, #e8f4fd); + border: 1px solid var(--border-info, #b3d9f2); + border-radius: 6px; + font-size: 13px; + line-height: 1.5; + color: var(--text-primary, #333); +} + .spk-filter-btn { padding: 6px 10px; border: 1px solid #d1d5db; @@ -888,7 +899,17 @@ return; } - segmentList.innerHTML = segments.map(seg => ` + // Sort segments: by null proportion when needs_review, otherwise by key (chronological) + const sorted = [...segments].sort((a, b) => { + if (currentFilter === 'needs_review') { + const aProp = a.attribution_total ? a.attribution_null / a.attribution_total : 0; + const bProp = b.attribution_total ? b.attribution_null / b.attribution_total : 0; + if (bProp !== aProp) return bProp - aProp; + } + return a.key.localeCompare(b.key); + }); + + segmentList.innerHTML = sorted.map(seg => `
${seg.start} - ${seg.end} @@ -1104,6 +1125,13 @@
+ ${(() => { + const seg = selectedSegment; + const nonOwnerTotal = seg?.attribution_non_owner_total || 0; + const nullCount = seg?.attribution_null || 0; + const showPrompt = seg?.speaker_count > 0 && nonOwnerTotal > 0 && (nullCount / nonOwnerTotal) > 0.5; + return showPrompt ? `
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.
` : ''; + })()} ${!hasLabels ? '
Not yet attributed
' : ''} ${sentences.length === 0 ? '
No sentences match this filter
' : sentences.map(renderSentence).join('')} `; @@ -1112,6 +1140,7 @@ btn.addEventListener('click', () => { currentFilter = btn.dataset.filter; renderReviewList(); + renderSegmentList(); }); }); diff --git a/tests/baselines/api/speakers/speakers-segment.json b/tests/baselines/api/speakers/speakers-segment.json index 36294c642..f4a45f3a6 100644 --- a/tests/baselines/api/speakers/speakers-segment.json +++ b/tests/baselines/api/speakers/speakers-segment.json @@ -1,4 +1,15 @@ { - "matched": [], + "matched": [ + { + "detected_name": "Juliet Capulet", + "entity_name": "Juliet Capulet", + "entity_type": "Person" + }, + { + "detected_name": "Mercutio", + "entity_name": "Mercutio Escalus", + "entity_type": "Person" + } + ], "unmatched": [] } diff --git a/tests/fixtures/journal/20260304/default/090000_300/agents/speakers.json b/tests/fixtures/journal/20260304/default/090000_300/agents/speakers.json new file mode 100644 index 000000000..4bea8863a --- /dev/null +++ b/tests/fixtures/journal/20260304/default/090000_300/agents/speakers.json @@ -0,0 +1 @@ +["Juliet Capulet", "Mercutio"] -- 2.51.2