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 ( + '