diff --git a/solstone/apps/transcripts/call.py b/solstone/apps/transcripts/call.py index a2012da06..c5d9e81b9 100644 --- a/solstone/apps/transcripts/call.py +++ b/solstone/apps/transcripts/call.py @@ -99,7 +99,7 @@ def _speaker_rows(payload: dict) -> list[dict]: "time": chunk.get("time", ""), "text": chunk.get("markdown", ""), "has_embedding": bool(chunk.get("has_embedding")), - "actionable": bool(chunk.get("has_embedding")), + "actionable": bool(chunk.get("speaker_actionable")), "speaker": label, } ) diff --git a/solstone/apps/transcripts/routes.py b/solstone/apps/transcripts/routes.py index b8947eaa6..8bfc26503 100644 --- a/solstone/apps/transcripts/routes.py +++ b/solstone/apps/transcripts/routes.py @@ -977,13 +977,13 @@ def segment_content(day: str, stream: str, segment_key: str) -> Any: labels_data = json.load(f) if not isinstance(labels_data, dict): raise ValueError("speaker labels payload must be an object") - speaker_labels_loaded = True principal = get_journal_principal() principal_id = principal["id"] if principal else None entity_cache: dict[str, dict | None] = {} labels = labels_data.get("labels", []) if not isinstance(labels, list): raise ValueError("speaker labels must be a list") + speaker_labels_loaded = True for label in labels: if not isinstance(label, dict): continue @@ -1118,6 +1118,13 @@ def segment_content(day: str, stream: str, segment_key: str) -> Any: "has_embedding": bool( chunk_sid and chunk_sid in embedding_statement_ids ), + "speaker_actionable": bool( + speaker_labels_present + and speaker_labels_loaded + and labels_source == speaker_source + and chunk_sid + and chunk_sid in embedding_statement_ids + ), "source_ref": { "start": time_str, "source": source.get("source"), diff --git a/solstone/apps/transcripts/tests/test_call.py b/solstone/apps/transcripts/tests/test_call.py index 2e05fb5ce..0821b94be 100644 --- a/solstone/apps/transcripts/tests/test_call.py +++ b/solstone/apps/transcripts/tests/test_call.py @@ -92,6 +92,7 @@ def _speaker_payload() -> dict: "time": "00:00:05", "markdown": "(mic) hello", "has_embedding": True, + "speaker_actionable": True, "speaker_label": { "name": "Romeo Montague", "entity_id": "romeo_montague", @@ -106,7 +107,8 @@ def _speaker_payload() -> dict: "speaker_source": "audio", "time": "00:00:20", "markdown": "(mic) unlabeled", - "has_embedding": False, + "has_embedding": True, + "speaker_actionable": False, }, { "type": "screen", @@ -272,7 +274,7 @@ def test_speakers_json_output_exposes_sentence_ids_and_sources( "speaker_source": "audio", "time": "00:00:20", "text": "(mic) unlabeled", - "has_embedding": False, + "has_embedding": True, "actionable": False, "speaker": None, }, diff --git a/solstone/apps/transcripts/tests/test_copy.py b/solstone/apps/transcripts/tests/test_copy.py index 4da9375ba..2fda44a62 100644 --- a/solstone/apps/transcripts/tests/test_copy.py +++ b/solstone/apps/transcripts/tests/test_copy.py @@ -38,16 +38,11 @@ def test_no_literal_copy_in_templates(): def test_copy_payload_reflects_tr_constants_only(): payload = transcripts_copy_payload() - assert payload["TR_SPEAKER_HEDGE_PROBABLE"] == "probably {name}" - assert payload["TR_SPEAKER_HEDGE_MAYBE"] == "maybe {name}?" - assert payload["TR_SPEAKER_UNKNOWN_CHIP"] == "unknown voice" - assert payload["TR_SPEAKER_PROPAGATION_OFFER"] == ( - "{count} more statements may need this change" - ) - assert payload["TR_SPEAKER_ALREADY_CORRECT"] == "already set" + assert payload assert SPEAKER_LABELS_UNAVAILABLE_MESSAGE not in payload.values() assert SPEAKER_LABEL_SOURCE_AMBIGUOUS_MESSAGE not in payload.values() assert all(name.startswith("TR_") for name in payload) + assert all(isinstance(value, str) for value in payload.values()) def test_all_copy_constants_referenced_by_render_surface(): diff --git a/solstone/apps/transcripts/tests/test_segment_routes.py b/solstone/apps/transcripts/tests/test_segment_routes.py index 180b37ea3..0c97f8ece 100644 --- a/solstone/apps/transcripts/tests/test_segment_routes.py +++ b/solstone/apps/transcripts/tests/test_segment_routes.py @@ -659,6 +659,7 @@ def test_segment_content_adds_speaker_provenance_without_embeddings(client): assert all(chunk["speaker_source"] == "audio" for chunk in audio_chunks) assert all(chunk["source_ref"]["source"] == "mic" for chunk in audio_chunks) assert all(chunk["has_embedding"] is False for chunk in audio_chunks) + assert all(chunk["speaker_actionable"] is False for chunk in audio_chunks) assert audio_chunks[0]["speaker_label"] == { "name": "Romeo Montague", "entity_id": "romeo_montague", @@ -692,9 +693,13 @@ def test_segment_content_marks_seeded_embedding_statement_ids(client, journal_co if chunk["type"] == "audio" } assert audio_by_sid[1]["has_embedding"] is True + assert audio_by_sid[1]["speaker_actionable"] is True assert audio_by_sid[2]["has_embedding"] is False + assert audio_by_sid[2]["speaker_actionable"] is False assert audio_by_sid[4]["has_embedding"] is True + assert audio_by_sid[4]["speaker_actionable"] is True assert audio_by_sid[5]["has_embedding"] is False + assert audio_by_sid[5]["speaker_actionable"] is False def test_segment_content_keeps_missing_confidence_label_as_unknown( @@ -762,6 +767,48 @@ def test_segment_content_malformed_speaker_labels_warns(client, journal_copy): for chunk in data["chunks"] if chunk["type"] == "audio" ) + assert all( + chunk["speaker_actionable"] is False + for chunk in data["chunks"] + if chunk["type"] == "audio" + ) + + +def test_segment_content_structurally_bad_speaker_labels_are_not_loaded( + client, + journal_copy, +): + segment_dir = ( + journal_copy / "chronicle" / FIXTURE_DAY / FIXTURE_STREAM / FIXTURE_SEGMENT + ) + _write_embedding_npz(segment_dir, statement_ids=(1,)) + labels_path = segment_dir / "talents" / "speaker_labels.json" + labels_path.write_text( + json.dumps({"labels": {}, "owner_centroid_last_refreshed_at": "test"}) + "\n", + encoding="utf-8", + ) + + response = client.get( + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["speaker_labels"] == { + "present": True, + "loaded": False, + "source": "audio", + "ambiguous": False, + } + assert any( + detail["type"] == "speaker_labels" + and detail["file"] == str(labels_path) + and detail["message"] == SPEAKER_LABELS_UNAVAILABLE_MESSAGE + for detail in data["warning_details"] + ) + audio_chunks = [chunk for chunk in data["chunks"] if chunk["type"] == "audio"] + assert audio_chunks[0]["has_embedding"] is True + assert audio_chunks[0]["speaker_actionable"] is False def test_segment_content_ambiguous_audio_sources_do_not_join_labels( @@ -818,9 +865,68 @@ def test_segment_content_ambiguous_audio_sources_do_not_join_labels( "mic_audio", } assert all(chunk["has_embedding"] is False for chunk in audio_chunks) + assert all(chunk["speaker_actionable"] is False for chunk in audio_chunks) assert all("speaker_label" not in chunk for chunk in audio_chunks) +def test_segment_content_only_labels_source_is_actionable_with_multiple_npz( + client, + journal_copy, +): + day = "20990107" + stream = "default" + segment = "091000_300" + _write_segment(journal_copy, day, stream, segment, screen=False) + segment_dir = journal_copy / "chronicle" / day / stream / segment + _write_jsonl( + segment_dir / "mic_audio.jsonl", + [ + {"raw": "mic_audio.flac"}, + { + "start": "00:00:02", + "source": "mic", + "speaker": 2, + "text": "mic source line", + }, + ], + ) + _write_embedding_npz(segment_dir, source="audio", statement_ids=(1,)) + _write_embedding_npz(segment_dir, source="mic_audio", statement_ids=(1,)) + _write_speaker_labels( + segment_dir, + [ + { + "sentence_id": 1, + "speaker": "romeo_montague", + "confidence": "high", + "method": "owner_centroid", + } + ], + ) + + response = client.get(f"/app/transcripts/api/segment/{day}/{stream}/{segment}") + + assert response.status_code == 200 + data = response.get_json() + assert data["speaker_labels"] == { + "present": True, + "loaded": True, + "source": "audio", + "ambiguous": False, + } + audio_by_source = { + chunk["speaker_source"]: chunk + for chunk in data["chunks"] + if chunk["type"] == "audio" + } + assert audio_by_source["audio"]["has_embedding"] is True + assert audio_by_source["audio"]["speaker_actionable"] is True + assert audio_by_source["audio"]["speaker_label"]["entity_id"] == "romeo_montague" + assert audio_by_source["mic_audio"]["has_embedding"] is True + assert audio_by_source["mic_audio"]["speaker_actionable"] is False + assert "speaker_label" not in audio_by_source["mic_audio"] + + def test_segment_content_invalid_embedding_npz_is_not_route_error( client, journal_copy, @@ -851,6 +957,7 @@ def test_segment_content_invalid_embedding_npz_is_not_route_error( audio_chunks = [chunk for chunk in data["chunks"] if chunk["type"] == "audio"] assert audio_chunks[0]["speaker_label"]["entity_id"] == "romeo_montague" assert audio_chunks[0]["has_embedding"] is False + assert audio_chunks[0]["speaker_actionable"] is False def test_segment_content_merges_browser_between_audio_chunks( diff --git a/solstone/apps/transcripts/tests/test_workspace_html_invariants.py b/solstone/apps/transcripts/tests/test_workspace_html_invariants.py index e8409cb73..95394108b 100644 --- a/solstone/apps/transcripts/tests/test_workspace_html_invariants.py +++ b/solstone/apps/transcripts/tests/test_workspace_html_invariants.py @@ -386,7 +386,8 @@ def test_workspace_html_speaker_picker_markup_and_data_contract(): assert "payload?.speakers" in text assert "payload.success" not in text assert "voices.length > 7" in text - assert 'href="/app/speakers#new-voices"' in text + assert 'href="/app/speakers"' in text + assert "#new-voices" not in text assert "Someone new" not in text @@ -433,6 +434,7 @@ def test_workspace_html_speaker_dispatch_and_local_rerender_contract(): assert "slot.innerHTML = renderSpeakerSlot(chunk" in text assert "renderLoadedSegmentData(data, activeTab)" not in text assert "loadSegmentContent(selectedSegment" not in text + assert "item.speaker_actionable !== true" in text assert "result?.status === 'already_correct'" in text assert "err.reasonCode === 'speaker_voiceprint_busy'" in text assert "err.reasonCode === 'speaker_labels_busy'" in text diff --git a/solstone/apps/transcripts/workspace.html b/solstone/apps/transcripts/workspace.html index 391d52e42..73bf23348 100644 --- a/solstone/apps/transcripts/workspace.html +++ b/solstone/apps/transcripts/workspace.html @@ -4458,6 +4458,9 @@ body.presentation-mode .tr-screen-text { font-size: 16px; padding: 12px 16px; bo if (!item.has_embedding) { return trCopy('TR_SPEAKER_NO_EMBEDDING'); } + if (item.speaker_actionable !== true) { + return trCopy('TR_SPEAKER_ACTION_UNAVAILABLE'); + } return ''; } @@ -4648,7 +4651,7 @@ body.presentation-mode .tr-screen-text { font-size: 16px; padding: 12px 16px; bo