From 7f096319c09a71e31a8037fbea9ff84bce7e40fe Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sat, 18 Jul 2026 10:59:54 -0600 Subject: [PATCH] Let owners review dense speaker candidate pairs Owners can now review acoustically similar dense speaker candidates from Suggestions with per-side audio evidence and stable anchor-based dismissal that survives candidate id churn. Accepting one of these suggestions performs a speaker candidate-pool merge, not an entity merge, and the curation response reports that the merge has no undo. Also wire the speakers suggestion generator and formatter for the new kind and rename the new store writer to record_candidate_pair. --- solstone/apps/curation/copy.py | 9 + solstone/apps/curation/routes.py | 67 +++++- solstone/apps/curation/tests/test_copy.py | 9 + solstone/apps/curation/tests/test_routes.py | 133 ++++++++++++ .../curation/tests/test_trust_core_surface.py | 18 ++ solstone/apps/curation/workspace.html | 134 +++++++++++- solstone/apps/speakers/maintenance.py | 22 +- solstone/apps/speakers/suggest.py | 35 +++ solstone/apps/speakers/tests/test_suggest.py | 32 +++ solstone/think/curation.py | 146 ++++++++++++- ...peaker_candidate_pair_review_candidates.py | 2 +- tests/test_curation.py | 200 ++++++++++++++++++ ...peaker_candidate_pair_review_candidates.py | 16 +- 13 files changed, 798 insertions(+), 25 deletions(-) diff --git a/solstone/apps/curation/copy.py b/solstone/apps/curation/copy.py index d38b0569a..7bb29d08e 100644 --- a/solstone/apps/curation/copy.py +++ b/solstone/apps/curation/copy.py @@ -23,6 +23,15 @@ CUR_SPEAKER_BODY = ( ) CUR_SPEAKER_MERGE_ACTION = "review merge" CUR_SPEAKER_DISMISS_ACTION = "keep separate" +CUR_SPEAKER_CANDIDATE_PAIR_BODY = ( + "solstone found two speaker candidates that sound alike. merge them?" +) +CUR_SPEAKER_CANDIDATE_PAIR_MERGE_ACTION = "merge candidates" +CUR_SPEAKER_CANDIDATE_PAIR_DISMISS_ACTION = "keep separate" +CUR_SPEAKER_CANDIDATE_PAIR_SIMILARITY_LABEL = "cosine" +CUR_SPEAKER_CANDIDATE_PAIR_INTERVALS_LABEL = "intervals" +CUR_SPEAKER_CANDIDATE_PAIR_SOURCE_LABEL = "candidate A" +CUR_SPEAKER_CANDIDATE_PAIR_TARGET_LABEL = "candidate B" CUR_EMPTY_STATE = ( "nothing to review — solstone hasn't spotted new structure to suggest." ) diff --git a/solstone/apps/curation/routes.py b/solstone/apps/curation/routes.py index e4926a7cb..9e41dbd16 100644 --- a/solstone/apps/curation/routes.py +++ b/solstone/apps/curation/routes.py @@ -17,20 +17,26 @@ from solstone.convey.reasons import ( MISSING_REQUIRED_FIELD, ) from solstone.convey.utils import error_response, respond_collection -from solstone.think import speaker_review_candidates +from solstone.think import ( + speaker_candidate_pair_review_candidates, + speaker_review_candidates, +) from solstone.think.curation import ( KIND_ENTITY_AMBIGUITY, KIND_ENTITY_MERGE, KIND_FACET_CANDIDATE, + KIND_SPEAKER_CANDIDATE_PAIR, KIND_SPEAKER_NAME_VARIANT, accept_entity_candidate, accept_entity_candidate_batch, accept_facet_candidate, accept_speaker_candidate, + accept_speaker_candidate_pair, dismiss_entity_candidate, dismiss_entity_candidate_batch, dismiss_facet_candidate, dismiss_speaker_candidate, + dismiss_speaker_candidate_pair, load_open_items, merge_preview_fields, ) @@ -68,6 +74,11 @@ def api_state() -> Response | tuple[Response, int]: for item in items if item.kind == KIND_SPEAKER_NAME_VARIANT ], + "speaker_candidate_pair_items": [ + item.to_dict() + for item in items + if item.kind == KIND_SPEAKER_CANDIDATE_PAIR + ], "copy": curation_copy.curation_copy_payload(), } ) @@ -114,6 +125,28 @@ def _speaker_payload( return key, source_id, target_id +def _speaker_candidate_pair_payload( + data: dict[str, Any], +) -> tuple[str, str, str] | tuple[Response, int]: + try: + key = str(_required(data, "key")) + anchor_a = str(_required(data, "anchor_a")) + anchor_b = str(_required(data, "anchor_b")) + except KeyError as exc: + return _missing_field(str(exc.args[0])) + + expected = speaker_candidate_pair_review_candidates.candidate_key( + anchor_a, + anchor_b, + ) + if key != expected: + return error_response( + INVALID_REQUEST_VALUE, + detail="key does not match anchor_a/anchor_b", + ) + return key, anchor_a, anchor_b + + @curation_bp.route("/api/facet/candidates") def facet_candidates() -> Response: return respond_collection(load_candidates()) @@ -219,6 +252,38 @@ def dismiss_speaker() -> Response | tuple[Response, int]: return _result_response(result) +@curation_bp.post("/api/speaker-candidate-pair/accept") +def accept_speaker_candidate_pair_route() -> Response | tuple[Response, int]: + payload = _speaker_candidate_pair_payload(_json_body()) + if not isinstance(payload, tuple) or len(payload) != 3: + return payload + _, anchor_a, anchor_b = payload + + try: + result = accept_speaker_candidate_pair(anchor_a, anchor_b) + except LockTimeout: + return error_response( + ENTITY_BUSY, detail="speaker suggestions are busy; try again" + ) + return _result_response(result) + + +@curation_bp.post("/api/speaker-candidate-pair/dismiss") +def dismiss_speaker_candidate_pair_route() -> Response | tuple[Response, int]: + payload = _speaker_candidate_pair_payload(_json_body()) + if not isinstance(payload, tuple) or len(payload) != 3: + return payload + _, anchor_a, anchor_b = payload + + try: + result = dismiss_speaker_candidate_pair(anchor_a, anchor_b) + except LockTimeout: + return error_response( + ENTITY_BUSY, detail="speaker suggestions are busy; try again" + ) + return _result_response(result) + + @curation_bp.post("/api/entity/accept") def accept_entity() -> Response | tuple[Response, int]: data = _json_body() diff --git a/solstone/apps/curation/tests/test_copy.py b/solstone/apps/curation/tests/test_copy.py index 55f7224c5..c1b83741b 100644 --- a/solstone/apps/curation/tests/test_copy.py +++ b/solstone/apps/curation/tests/test_copy.py @@ -84,6 +84,15 @@ def test_curation_copy_literals_are_folded_byte_for_byte(): ), "CUR_SPEAKER_MERGE_ACTION": "review merge", "CUR_SPEAKER_DISMISS_ACTION": "keep separate", + "CUR_SPEAKER_CANDIDATE_PAIR_BODY": ( + "solstone found two speaker candidates that sound alike. merge them?" + ), + "CUR_SPEAKER_CANDIDATE_PAIR_MERGE_ACTION": "merge candidates", + "CUR_SPEAKER_CANDIDATE_PAIR_DISMISS_ACTION": "keep separate", + "CUR_SPEAKER_CANDIDATE_PAIR_SIMILARITY_LABEL": "cosine", + "CUR_SPEAKER_CANDIDATE_PAIR_INTERVALS_LABEL": "intervals", + "CUR_SPEAKER_CANDIDATE_PAIR_SOURCE_LABEL": "candidate A", + "CUR_SPEAKER_CANDIDATE_PAIR_TARGET_LABEL": "candidate B", "CUR_EMPTY_STATE": ( "nothing to review — solstone hasn't spotted new structure to suggest." ), diff --git a/solstone/apps/curation/tests/test_routes.py b/solstone/apps/curation/tests/test_routes.py index 73da4cc60..2bc02ca60 100644 --- a/solstone/apps/curation/tests/test_routes.py +++ b/solstone/apps/curation/tests/test_routes.py @@ -26,6 +26,15 @@ from solstone.think.facet_review_candidates import ( ) from solstone.think.facet_review_candidates import record_facet_candidate from solstone.think.journal_io import LockTimeout +from solstone.think.speaker_candidate_pair_review_candidates import ( + candidate_key as pair_candidate_key, +) +from solstone.think.speaker_candidate_pair_review_candidates import ( + load_candidates as load_pair_candidates, +) +from solstone.think.speaker_candidate_pair_review_candidates import ( + record_candidate_pair, +) from solstone.think.speaker_review_candidates import ( candidate_key as speaker_candidate_key, ) @@ -150,6 +159,51 @@ def _speaker_payload( } +def _pair_anchors() -> tuple[str, str]: + return ( + '["20260101","090000_300","test","mic_audio",1]', + '["20260102","090000_300","test","mic_audio",2]', + ) + + +def _speaker_candidate_pair_payload( + anchor_a: str | None = None, + anchor_b: str | None = None, +) -> dict[str, str]: + left, right = _pair_anchors() + anchor_a = anchor_a or left + anchor_b = anchor_b or right + return { + "key": pair_candidate_key(anchor_a, anchor_b), + "anchor_a": anchor_a, + "anchor_b": anchor_b, + } + + +def _seed_speaker_candidate_pair() -> tuple[str, str]: + anchor_a, anchor_b = _pair_anchors() + sample = { + "day": "20260101", + "stream": "test", + "segment_key": "090000_300", + "source": "mic_audio", + "cluster_label": 1, + "audio_url": "/app/speakers/api/serve_audio/20260101/test/090000_300/mic_audio.flac", + } + record_candidate_pair( + source_anchor=anchor_a, + target_anchor=anchor_b, + source_anchors={anchor_a}, + target_anchors={anchor_b}, + similarity=0.62, + source_intervals=31, + target_intervals=35, + source_samples=[sample], + target_samples=[], + ) + return anchor_a, anchor_b + + def _embedding(vector: list[float]) -> np.ndarray: embedding = np.array(vector + [0.0] * (256 - len(vector)), dtype=np.float32) return embedding / np.linalg.norm(embedding) @@ -258,6 +312,7 @@ def test_index_renders_empty_state(curation_env): assert data["facet_items"] == [] assert data["entity_items"] == [] assert data["speaker_items"] == [] + assert data["speaker_candidate_pair_items"] == [] assert data["copy"]["CUR_HEADING"] == CUR_HEADING assert data["copy"]["CUR_EMPTY_STATE"] == CUR_EMPTY_STATE @@ -295,6 +350,28 @@ def test_index_renders_speaker_candidate(curation_env): assert data["speaker_items"][0]["target"] == "Alice Johnson" +def test_index_renders_speaker_candidate_pair_bucket(curation_env): + env = curation_env() + _seed_speaker_candidate_pair() + + resp = env.client.get("/app/curation/api/state") + + assert resp.status_code == 200 + data = resp.get_json() + assert data["speaker_candidate_pair_items"][0]["kind"] == "speaker_candidate_pair" + assert data["speaker_candidate_pair_items"][0]["evidence"]["similarity"] == 0.62 + assert ( + data["speaker_candidate_pair_items"][0]["evidence"]["source_samples"][0][ + "segment_key" + ] + == "090000_300" + ) + assert ( + "segment" + not in data["speaker_candidate_pair_items"][0]["evidence"]["source_samples"][0] + ) + + def test_facet_accept_creates_facet_and_flips_status(curation_env): env = curation_env() _seed_facet_candidate() @@ -805,6 +882,60 @@ def test_speaker_payload_key_mismatch_returns_400(curation_env): assert resp.get_json()["reason_code"] == "invalid_request_value" +def test_speaker_candidate_pair_dismiss_sets_status_and_removes_open_item(curation_env): + env = curation_env() + _seed_speaker_candidate_pair() + + resp = env.client.post( + "/app/curation/api/speaker-candidate-pair/dismiss", + json=_speaker_candidate_pair_payload(), + ) + + assert resp.status_code == 200 + assert resp.get_json()["status"] == "dismissed" + assert load_pair_candidates()[0]["status"] == "dismissed" + state = env.client.get("/app/curation/api/state").get_json() + assert state["speaker_candidate_pair_items"] == [] + + +def test_speaker_candidate_pair_payload_key_mismatch_returns_400(curation_env): + env = curation_env() + + resp = env.client.post( + "/app/curation/api/speaker-candidate-pair/accept", + json={ + "key": "wrong", + "anchor_a": _pair_anchors()[0], + "anchor_b": _pair_anchors()[1], + }, + ) + + assert resp.status_code == 400 + assert resp.get_json()["reason_code"] == "invalid_request_value" + + +def test_speaker_candidate_pair_accept_lock_timeout_returns_busy( + curation_env, + monkeypatch, +): + env = curation_env() + _seed_speaker_candidate_pair() + from solstone.apps.curation import routes + + def raise_busy(anchor_a: str, anchor_b: str) -> dict[str, Any]: + raise LockTimeout(path=env.journal / "speakers" / "busy.jsonl", timeout=0.0) + + monkeypatch.setattr(routes, "accept_speaker_candidate_pair", raise_busy) + + resp = env.client.post( + "/app/curation/api/speaker-candidate-pair/accept", + json=_speaker_candidate_pair_payload(), + ) + + assert resp.status_code == 503 + assert resp.get_json()["reason_code"] == "entity_busy" + + def test_rendered_payload_matches_copy_source(curation_env): env = curation_env() @@ -862,10 +993,12 @@ def test_curation_state_payload_shape_includes_nested_evidence(curation_env): "copy", "entity_items", "facet_items", + "speaker_candidate_pair_items", "speaker_items", } assert data["ambiguity_items"] == [] assert data["entity_items"] == [] + assert data["speaker_candidate_pair_items"] == [] assert data["speaker_items"] == [] assert data["facet_items"][0]["evidence"]["samples"] == [ {"day": "20260602", "stream": "archon", "segment": "090000_300"} diff --git a/solstone/apps/curation/tests/test_trust_core_surface.py b/solstone/apps/curation/tests/test_trust_core_surface.py index 5d16ef771..ea61bcae9 100644 --- a/solstone/apps/curation/tests/test_trust_core_surface.py +++ b/solstone/apps/curation/tests/test_trust_core_surface.py @@ -53,6 +53,24 @@ def test_removing_final_item_does_not_reload_away_undo_outcomes() -> None: assert "refreshToolbar();" in html[start:end] +def test_speaker_candidate_pair_renderer_includes_audio_and_actions() -> None: + html = _workspace() + + assert "function speakerCandidatePairItemHtml(item)" in html + assert "payload.speaker_candidate_pair_items || []" in html + assert 'data-kind="speaker_candidate_pair"' in html + assert 'data-anchor-a="${escapeAttr(item.source_slug)}"' in html + assert 'data-action="speaker-pair-accept"' in html + assert 'data-action="speaker-pair-dismiss"' in html + assert ( + '