personal memory agent
0

Configure Feed

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

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.

+798 -25
+9
solstone/apps/curation/copy.py
··· 23 23 ) 24 24 CUR_SPEAKER_MERGE_ACTION = "review merge" 25 25 CUR_SPEAKER_DISMISS_ACTION = "keep separate" 26 + CUR_SPEAKER_CANDIDATE_PAIR_BODY = ( 27 + "solstone found two speaker candidates that sound alike. merge them?" 28 + ) 29 + CUR_SPEAKER_CANDIDATE_PAIR_MERGE_ACTION = "merge candidates" 30 + CUR_SPEAKER_CANDIDATE_PAIR_DISMISS_ACTION = "keep separate" 31 + CUR_SPEAKER_CANDIDATE_PAIR_SIMILARITY_LABEL = "cosine" 32 + CUR_SPEAKER_CANDIDATE_PAIR_INTERVALS_LABEL = "intervals" 33 + CUR_SPEAKER_CANDIDATE_PAIR_SOURCE_LABEL = "candidate A" 34 + CUR_SPEAKER_CANDIDATE_PAIR_TARGET_LABEL = "candidate B" 26 35 CUR_EMPTY_STATE = ( 27 36 "nothing to review — solstone hasn't spotted new structure to suggest." 28 37 )
+66 -1
solstone/apps/curation/routes.py
··· 17 17 MISSING_REQUIRED_FIELD, 18 18 ) 19 19 from solstone.convey.utils import error_response, respond_collection 20 - from solstone.think import speaker_review_candidates 20 + from solstone.think import ( 21 + speaker_candidate_pair_review_candidates, 22 + speaker_review_candidates, 23 + ) 21 24 from solstone.think.curation import ( 22 25 KIND_ENTITY_AMBIGUITY, 23 26 KIND_ENTITY_MERGE, 24 27 KIND_FACET_CANDIDATE, 28 + KIND_SPEAKER_CANDIDATE_PAIR, 25 29 KIND_SPEAKER_NAME_VARIANT, 26 30 accept_entity_candidate, 27 31 accept_entity_candidate_batch, 28 32 accept_facet_candidate, 29 33 accept_speaker_candidate, 34 + accept_speaker_candidate_pair, 30 35 dismiss_entity_candidate, 31 36 dismiss_entity_candidate_batch, 32 37 dismiss_facet_candidate, 33 38 dismiss_speaker_candidate, 39 + dismiss_speaker_candidate_pair, 34 40 load_open_items, 35 41 merge_preview_fields, 36 42 ) ··· 68 74 for item in items 69 75 if item.kind == KIND_SPEAKER_NAME_VARIANT 70 76 ], 77 + "speaker_candidate_pair_items": [ 78 + item.to_dict() 79 + for item in items 80 + if item.kind == KIND_SPEAKER_CANDIDATE_PAIR 81 + ], 71 82 "copy": curation_copy.curation_copy_payload(), 72 83 } 73 84 ) ··· 114 125 return key, source_id, target_id 115 126 116 127 128 + def _speaker_candidate_pair_payload( 129 + data: dict[str, Any], 130 + ) -> tuple[str, str, str] | tuple[Response, int]: 131 + try: 132 + key = str(_required(data, "key")) 133 + anchor_a = str(_required(data, "anchor_a")) 134 + anchor_b = str(_required(data, "anchor_b")) 135 + except KeyError as exc: 136 + return _missing_field(str(exc.args[0])) 137 + 138 + expected = speaker_candidate_pair_review_candidates.candidate_key( 139 + anchor_a, 140 + anchor_b, 141 + ) 142 + if key != expected: 143 + return error_response( 144 + INVALID_REQUEST_VALUE, 145 + detail="key does not match anchor_a/anchor_b", 146 + ) 147 + return key, anchor_a, anchor_b 148 + 149 + 117 150 @curation_bp.route("/api/facet/candidates") 118 151 def facet_candidates() -> Response: 119 152 return respond_collection(load_candidates()) ··· 212 245 213 246 try: 214 247 result = dismiss_speaker_candidate(source_id, target_id) 248 + except LockTimeout: 249 + return error_response( 250 + ENTITY_BUSY, detail="speaker suggestions are busy; try again" 251 + ) 252 + return _result_response(result) 253 + 254 + 255 + @curation_bp.post("/api/speaker-candidate-pair/accept") 256 + def accept_speaker_candidate_pair_route() -> Response | tuple[Response, int]: 257 + payload = _speaker_candidate_pair_payload(_json_body()) 258 + if not isinstance(payload, tuple) or len(payload) != 3: 259 + return payload 260 + _, anchor_a, anchor_b = payload 261 + 262 + try: 263 + result = accept_speaker_candidate_pair(anchor_a, anchor_b) 264 + except LockTimeout: 265 + return error_response( 266 + ENTITY_BUSY, detail="speaker suggestions are busy; try again" 267 + ) 268 + return _result_response(result) 269 + 270 + 271 + @curation_bp.post("/api/speaker-candidate-pair/dismiss") 272 + def dismiss_speaker_candidate_pair_route() -> Response | tuple[Response, int]: 273 + payload = _speaker_candidate_pair_payload(_json_body()) 274 + if not isinstance(payload, tuple) or len(payload) != 3: 275 + return payload 276 + _, anchor_a, anchor_b = payload 277 + 278 + try: 279 + result = dismiss_speaker_candidate_pair(anchor_a, anchor_b) 215 280 except LockTimeout: 216 281 return error_response( 217 282 ENTITY_BUSY, detail="speaker suggestions are busy; try again"
+9
solstone/apps/curation/tests/test_copy.py
··· 84 84 ), 85 85 "CUR_SPEAKER_MERGE_ACTION": "review merge", 86 86 "CUR_SPEAKER_DISMISS_ACTION": "keep separate", 87 + "CUR_SPEAKER_CANDIDATE_PAIR_BODY": ( 88 + "solstone found two speaker candidates that sound alike. merge them?" 89 + ), 90 + "CUR_SPEAKER_CANDIDATE_PAIR_MERGE_ACTION": "merge candidates", 91 + "CUR_SPEAKER_CANDIDATE_PAIR_DISMISS_ACTION": "keep separate", 92 + "CUR_SPEAKER_CANDIDATE_PAIR_SIMILARITY_LABEL": "cosine", 93 + "CUR_SPEAKER_CANDIDATE_PAIR_INTERVALS_LABEL": "intervals", 94 + "CUR_SPEAKER_CANDIDATE_PAIR_SOURCE_LABEL": "candidate A", 95 + "CUR_SPEAKER_CANDIDATE_PAIR_TARGET_LABEL": "candidate B", 87 96 "CUR_EMPTY_STATE": ( 88 97 "nothing to review — solstone hasn't spotted new structure to suggest." 89 98 ),
+133
solstone/apps/curation/tests/test_routes.py
··· 26 26 ) 27 27 from solstone.think.facet_review_candidates import record_facet_candidate 28 28 from solstone.think.journal_io import LockTimeout 29 + from solstone.think.speaker_candidate_pair_review_candidates import ( 30 + candidate_key as pair_candidate_key, 31 + ) 32 + from solstone.think.speaker_candidate_pair_review_candidates import ( 33 + load_candidates as load_pair_candidates, 34 + ) 35 + from solstone.think.speaker_candidate_pair_review_candidates import ( 36 + record_candidate_pair, 37 + ) 29 38 from solstone.think.speaker_review_candidates import ( 30 39 candidate_key as speaker_candidate_key, 31 40 ) ··· 150 159 } 151 160 152 161 162 + def _pair_anchors() -> tuple[str, str]: 163 + return ( 164 + '["20260101","090000_300","test","mic_audio",1]', 165 + '["20260102","090000_300","test","mic_audio",2]', 166 + ) 167 + 168 + 169 + def _speaker_candidate_pair_payload( 170 + anchor_a: str | None = None, 171 + anchor_b: str | None = None, 172 + ) -> dict[str, str]: 173 + left, right = _pair_anchors() 174 + anchor_a = anchor_a or left 175 + anchor_b = anchor_b or right 176 + return { 177 + "key": pair_candidate_key(anchor_a, anchor_b), 178 + "anchor_a": anchor_a, 179 + "anchor_b": anchor_b, 180 + } 181 + 182 + 183 + def _seed_speaker_candidate_pair() -> tuple[str, str]: 184 + anchor_a, anchor_b = _pair_anchors() 185 + sample = { 186 + "day": "20260101", 187 + "stream": "test", 188 + "segment_key": "090000_300", 189 + "source": "mic_audio", 190 + "cluster_label": 1, 191 + "audio_url": "/app/speakers/api/serve_audio/20260101/test/090000_300/mic_audio.flac", 192 + } 193 + record_candidate_pair( 194 + source_anchor=anchor_a, 195 + target_anchor=anchor_b, 196 + source_anchors={anchor_a}, 197 + target_anchors={anchor_b}, 198 + similarity=0.62, 199 + source_intervals=31, 200 + target_intervals=35, 201 + source_samples=[sample], 202 + target_samples=[], 203 + ) 204 + return anchor_a, anchor_b 205 + 206 + 153 207 def _embedding(vector: list[float]) -> np.ndarray: 154 208 embedding = np.array(vector + [0.0] * (256 - len(vector)), dtype=np.float32) 155 209 return embedding / np.linalg.norm(embedding) ··· 258 312 assert data["facet_items"] == [] 259 313 assert data["entity_items"] == [] 260 314 assert data["speaker_items"] == [] 315 + assert data["speaker_candidate_pair_items"] == [] 261 316 assert data["copy"]["CUR_HEADING"] == CUR_HEADING 262 317 assert data["copy"]["CUR_EMPTY_STATE"] == CUR_EMPTY_STATE 263 318 ··· 293 348 assert data["speaker_items"][0]["kind"] == "speaker_name_variant" 294 349 assert data["speaker_items"][0]["source"] == "Alice" 295 350 assert data["speaker_items"][0]["target"] == "Alice Johnson" 351 + 352 + 353 + def test_index_renders_speaker_candidate_pair_bucket(curation_env): 354 + env = curation_env() 355 + _seed_speaker_candidate_pair() 356 + 357 + resp = env.client.get("/app/curation/api/state") 358 + 359 + assert resp.status_code == 200 360 + data = resp.get_json() 361 + assert data["speaker_candidate_pair_items"][0]["kind"] == "speaker_candidate_pair" 362 + assert data["speaker_candidate_pair_items"][0]["evidence"]["similarity"] == 0.62 363 + assert ( 364 + data["speaker_candidate_pair_items"][0]["evidence"]["source_samples"][0][ 365 + "segment_key" 366 + ] 367 + == "090000_300" 368 + ) 369 + assert ( 370 + "segment" 371 + not in data["speaker_candidate_pair_items"][0]["evidence"]["source_samples"][0] 372 + ) 296 373 297 374 298 375 def test_facet_accept_creates_facet_and_flips_status(curation_env): ··· 805 882 assert resp.get_json()["reason_code"] == "invalid_request_value" 806 883 807 884 885 + def test_speaker_candidate_pair_dismiss_sets_status_and_removes_open_item(curation_env): 886 + env = curation_env() 887 + _seed_speaker_candidate_pair() 888 + 889 + resp = env.client.post( 890 + "/app/curation/api/speaker-candidate-pair/dismiss", 891 + json=_speaker_candidate_pair_payload(), 892 + ) 893 + 894 + assert resp.status_code == 200 895 + assert resp.get_json()["status"] == "dismissed" 896 + assert load_pair_candidates()[0]["status"] == "dismissed" 897 + state = env.client.get("/app/curation/api/state").get_json() 898 + assert state["speaker_candidate_pair_items"] == [] 899 + 900 + 901 + def test_speaker_candidate_pair_payload_key_mismatch_returns_400(curation_env): 902 + env = curation_env() 903 + 904 + resp = env.client.post( 905 + "/app/curation/api/speaker-candidate-pair/accept", 906 + json={ 907 + "key": "wrong", 908 + "anchor_a": _pair_anchors()[0], 909 + "anchor_b": _pair_anchors()[1], 910 + }, 911 + ) 912 + 913 + assert resp.status_code == 400 914 + assert resp.get_json()["reason_code"] == "invalid_request_value" 915 + 916 + 917 + def test_speaker_candidate_pair_accept_lock_timeout_returns_busy( 918 + curation_env, 919 + monkeypatch, 920 + ): 921 + env = curation_env() 922 + _seed_speaker_candidate_pair() 923 + from solstone.apps.curation import routes 924 + 925 + def raise_busy(anchor_a: str, anchor_b: str) -> dict[str, Any]: 926 + raise LockTimeout(path=env.journal / "speakers" / "busy.jsonl", timeout=0.0) 927 + 928 + monkeypatch.setattr(routes, "accept_speaker_candidate_pair", raise_busy) 929 + 930 + resp = env.client.post( 931 + "/app/curation/api/speaker-candidate-pair/accept", 932 + json=_speaker_candidate_pair_payload(), 933 + ) 934 + 935 + assert resp.status_code == 503 936 + assert resp.get_json()["reason_code"] == "entity_busy" 937 + 938 + 808 939 def test_rendered_payload_matches_copy_source(curation_env): 809 940 env = curation_env() 810 941 ··· 862 993 "copy", 863 994 "entity_items", 864 995 "facet_items", 996 + "speaker_candidate_pair_items", 865 997 "speaker_items", 866 998 } 867 999 assert data["ambiguity_items"] == [] 868 1000 assert data["entity_items"] == [] 1001 + assert data["speaker_candidate_pair_items"] == [] 869 1002 assert data["speaker_items"] == [] 870 1003 assert data["facet_items"][0]["evidence"]["samples"] == [ 871 1004 {"day": "20260602", "stream": "archon", "segment": "090000_300"}
+18
solstone/apps/curation/tests/test_trust_core_surface.py
··· 53 53 assert "refreshToolbar();" in html[start:end] 54 54 55 55 56 + def test_speaker_candidate_pair_renderer_includes_audio_and_actions() -> None: 57 + html = _workspace() 58 + 59 + assert "function speakerCandidatePairItemHtml(item)" in html 60 + assert "payload.speaker_candidate_pair_items || []" in html 61 + assert 'data-kind="speaker_candidate_pair"' in html 62 + assert 'data-anchor-a="${escapeAttr(item.source_slug)}"' in html 63 + assert 'data-action="speaker-pair-accept"' in html 64 + assert 'data-action="speaker-pair-dismiss"' in html 65 + assert ( 66 + '<audio controls preload="metadata" src="${escapeHtml(sample.audio_url)}">' 67 + in html 68 + ) 69 + assert "sample.segment_key" in html 70 + assert "/app/curation/api/speaker-candidate-pair/accept" in html 71 + assert "/app/curation/api/speaker-candidate-pair/dismiss" in html 72 + 73 + 56 74 def test_repair_required_disables_replay_and_shows_remediation() -> None: 57 75 html = _workspace() 58 76
+133 -1
solstone/apps/curation/workspace.html
··· 207 207 font-size: 0.9em; 208 208 } 209 209 210 + .curation-speaker-pair-summary { 211 + margin: 0 0 0.65em; 212 + color: #4b5563; 213 + line-height: 1.45; 214 + } 215 + 216 + .curation-speaker-pair-samples { 217 + display: grid; 218 + gap: 0.75em; 219 + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); 220 + } 221 + 222 + .curation-speaker-pair-samples h3 { 223 + margin: 0 0 0.45em; 224 + color: #374151; 225 + font-size: 1em; 226 + font-weight: 650; 227 + } 228 + 229 + .curation-speaker-pair-samples ul { 230 + margin: 0; 231 + padding-left: 1.2em; 232 + color: #4b5563; 233 + line-height: 1.45; 234 + } 235 + 236 + .curation-speaker-pair-samples audio { 237 + display: block; 238 + width: 100%; 239 + margin-top: 0.35em; 240 + } 241 + 210 242 @media (max-width: 640px) { 211 243 .curation-shell { 212 244 padding: 1em; ··· 276 308 `).join(''); 277 309 } 278 310 311 + function formatSimilarity(value) { 312 + const number = Number(value || 0); 313 + return Number.isFinite(number) ? number.toFixed(2) : '0.00'; 314 + } 315 + 316 + function sampleAudioHtml(sample) { 317 + if (!sample?.audio_url) return ''; 318 + return `<audio controls preload="metadata" src="${escapeHtml(sample.audio_url)}"></audio>`; 319 + } 320 + 321 + function speakerCandidatePairSampleHtml(sample) { 322 + return ` 323 + <li> 324 + <span>${escapeHtml(sample.day)}</span> 325 + ${sample.stream ? `<span> · ${escapeHtml(sample.stream)}</span>` : ''} 326 + ${sample.segment_key ? `<span> · ${escapeHtml(sample.segment_key)}</span>` : ''} 327 + ${sample.source ? `<span> · ${escapeHtml(sample.source)}</span>` : ''} 328 + ${sample.cluster_label !== undefined ? `<span> · ${escapeHtml(sample.cluster_label)}</span>` : ''} 329 + ${sampleAudioHtml(sample)} 330 + </li> 331 + `; 332 + } 333 + 334 + function speakerCandidatePairEvidenceHtml(item) { 335 + const evidence = item.evidence || {}; 336 + const sourceSamples = evidence.source_samples || []; 337 + const targetSamples = evidence.target_samples || []; 338 + return ` 339 + <div class="curation-evidence"> 340 + <p class="curation-speaker-pair-summary"> 341 + ${escapeHtml(CUR_COPY.CUR_SPEAKER_CANDIDATE_PAIR_SIMILARITY_LABEL)}: ${escapeHtml(formatSimilarity(evidence.similarity))} 342 + · ${escapeHtml(CUR_COPY.CUR_SPEAKER_CANDIDATE_PAIR_SOURCE_LABEL)} ${escapeHtml(CUR_COPY.CUR_SPEAKER_CANDIDATE_PAIR_INTERVALS_LABEL)}: ${escapeHtml(evidence.source_intervals || 0)} 343 + · ${escapeHtml(CUR_COPY.CUR_SPEAKER_CANDIDATE_PAIR_TARGET_LABEL)} ${escapeHtml(CUR_COPY.CUR_SPEAKER_CANDIDATE_PAIR_INTERVALS_LABEL)}: ${escapeHtml(evidence.target_intervals || 0)} 344 + </p> 345 + <div class="curation-speaker-pair-samples"> 346 + <section> 347 + <h3>${escapeHtml(CUR_COPY.CUR_SPEAKER_CANDIDATE_PAIR_SOURCE_LABEL)}</h3> 348 + <ul>${sourceSamples.map(speakerCandidatePairSampleHtml).join('')}</ul> 349 + </section> 350 + <section> 351 + <h3>${escapeHtml(CUR_COPY.CUR_SPEAKER_CANDIDATE_PAIR_TARGET_LABEL)}</h3> 352 + <ul>${targetSamples.map(speakerCandidatePairSampleHtml).join('')}</ul> 353 + </section> 354 + </div> 355 + </div> 356 + `; 357 + } 358 + 279 359 function facetItemHtml(item) { 280 360 return ` 281 361 <article class="curation-item" data-curation-item data-kind="facet_candidate" data-name-key="${escapeHtml(item.key)}"> ··· 361 441 `; 362 442 } 363 443 444 + function speakerCandidatePairItemHtml(item) { 445 + return ` 446 + <article class="curation-item" data-curation-item data-kind="speaker_candidate_pair" data-key="${escapeAttr(item.key)}" data-anchor-a="${escapeAttr(item.source_slug)}" data-anchor-b="${escapeAttr(item.target_slug)}"> 447 + <p class="curation-body">${escapeHtml(CUR_COPY.CUR_SPEAKER_CANDIDATE_PAIR_BODY)}</p> 448 + ${speakerCandidatePairEvidenceHtml(item)} 449 + <div class="curation-actions"> 450 + <button class="curation-button curation-button-primary" type="button" data-action="speaker-pair-accept">${escapeHtml(CUR_COPY.CUR_SPEAKER_CANDIDATE_PAIR_MERGE_ACTION)}</button> 451 + <button class="curation-button" type="button" data-action="speaker-pair-dismiss">${escapeHtml(CUR_COPY.CUR_SPEAKER_CANDIDATE_PAIR_DISMISS_ACTION)}</button> 452 + </div> 453 + <p class="curation-item-error" hidden></p> 454 + </article> 455 + `; 456 + } 457 + 364 458 function renderState(payload, preserveOutcomes = false) { 365 459 const savedOutcomes = preserveOutcomes 366 460 ? Array.from(root.querySelector('[data-curation-outcomes]')?.children || []) ··· 371 465 const entityItems = payload.entity_items || []; 372 466 const ambiguityItems = payload.ambiguity_items || []; 373 467 const speakerItems = payload.speaker_items || []; 374 - const empty = !facetItems.length && !entityItems.length && !ambiguityItems.length && !speakerItems.length; 468 + const speakerCandidatePairItems = payload.speaker_candidate_pair_items || []; 469 + const empty = !facetItems.length && !entityItems.length && !ambiguityItems.length && !speakerItems.length && !speakerCandidatePairItems.length; 375 470 root.innerHTML = ` 376 471 <h1 class="curation-header">${escapeHtml(CUR_COPY.CUR_HEADING)}</h1> 377 472 ${empty ? `<p class="curation-empty">${escapeHtml(CUR_COPY.CUR_EMPTY_STATE)}</p>` : ''} ··· 382 477 ${entityItems.map(entityItemHtml).join('')} 383 478 ${ambiguityItems.map(ambiguityItemHtml).join('')} 384 479 ${speakerItems.map(speakerItemHtml).join('')} 480 + ${speakerCandidatePairItems.map(speakerCandidatePairItemHtml).join('')} 385 481 </div> 386 482 `; 387 483 const outcomes = root.querySelector('[data-curation-outcomes]'); ··· 457 553 return holder.innerHTML; 458 554 } 459 555 556 + function escapeAttr(value) { 557 + return escapeHtml(value).replace(/"/g, '&quot;'); 558 + } 559 + 460 560 function removeItem(item) { 461 561 if (!item) return; 462 562 item.dataset.removing = 'true'; ··· 585 685 key: ds.key, 586 686 source_id: ds.sourceId, 587 687 target_id: ds.targetId 688 + }; 689 + } 690 + 691 + function speakerCandidatePairPayload(button) { 692 + const item = itemFor(button); 693 + const ds = item?.dataset || {}; 694 + return { 695 + key: ds.key, 696 + anchor_a: ds.anchorA, 697 + anchor_b: ds.anchorB 588 698 }; 589 699 } 590 700 ··· 753 863 }) 754 864 .catch((err) => { 755 865 if (action === 'speaker-accept') { 866 + showMutationError(item, button, err, CUR_COPY.CUR_UNDO_FAILED); 867 + } else { 868 + setBusy(button, false); 869 + showError(item, err.serverMessage || err.message); 870 + } 871 + }); 872 + return; 873 + } 874 + 875 + if (action === 'speaker-pair-accept' || action === 'speaker-pair-dismiss') { 876 + const payload = speakerCandidatePairPayload(button); 877 + const path = action === 'speaker-pair-accept' 878 + ? '/app/curation/api/speaker-candidate-pair/accept' 879 + : '/app/curation/api/speaker-candidate-pair/dismiss'; 880 + setBusy(button, true); 881 + postJson(path, payload) 882 + .then((data) => { 883 + if (action === 'speaker-pair-accept') appendUndoOutcome(data.undo, data.key); 884 + removeItem(item); 885 + }) 886 + .catch((err) => { 887 + if (action === 'speaker-pair-accept') { 756 888 showMutationError(item, button, err, CUR_COPY.CUR_UNDO_FAILED); 757 889 } else { 758 890 setBusy(button, false);
+10 -12
solstone/apps/speakers/maintenance.py
··· 135 135 continue 136 136 found += 1 137 137 try: 138 - row, was_created, was_suppressed = ( 139 - pair_store.record_candidate_pair_candidate( 140 - source_anchor=canonical_candidate_anchor(left), 141 - target_anchor=canonical_candidate_anchor(right), 142 - source_anchors=candidate_source_anchors(left), 143 - target_anchors=candidate_source_anchors(right), 144 - similarity=score, 145 - source_intervals=left.n_intervals, 146 - target_intervals=right.n_intervals, 147 - source_samples=_candidate_samples(left), 148 - target_samples=_candidate_samples(right), 149 - ) 138 + row, was_created, was_suppressed = pair_store.record_candidate_pair( 139 + source_anchor=canonical_candidate_anchor(left), 140 + target_anchor=canonical_candidate_anchor(right), 141 + source_anchors=candidate_source_anchors(left), 142 + target_anchors=candidate_source_anchors(right), 143 + similarity=score, 144 + source_intervals=left.n_intervals, 145 + target_intervals=right.n_intervals, 146 + source_samples=_candidate_samples(left), 147 + target_samples=_candidate_samples(right), 150 148 ) 151 149 except LockTimeout as exc: 152 150 logger.warning("speaker candidate-pair suggestions skipped: %s", exc)
+35
solstone/apps/speakers/suggest.py
··· 324 324 return results 325 325 326 326 327 + def _candidate_pair_review() -> list[dict[str, Any]]: 328 + from solstone.think import speaker_candidate_pair_review_candidates as pair_store 329 + 330 + results: list[dict[str, Any]] = [] 331 + for row in pair_store.load_candidates(): 332 + if row.get("status") != "open": 333 + continue 334 + evidence = row.get("evidence", {}) 335 + if not isinstance(evidence, dict): 336 + evidence = {} 337 + similarity = float(row.get("similarity") or evidence.get("similarity") or 0.0) 338 + results.append( 339 + { 340 + "type": "speaker_candidate_pair", 341 + "key": row.get("key"), 342 + "anchor_a": row.get("anchor_a"), 343 + "anchor_b": row.get("anchor_b"), 344 + "similarity": similarity, 345 + "source_intervals": evidence.get("source_intervals", 0), 346 + "target_intervals": evidence.get("target_intervals", 0), 347 + } 348 + ) 349 + 350 + results.sort(key=lambda item: (-float(item["similarity"]), str(item["key"] or ""))) 351 + return results 352 + 353 + 327 354 def suggest_opportunities(limit: int = 5) -> list[dict[str, Any]]: 328 355 suggestions: list[dict[str, Any]] = [] 329 356 for generator in [ 330 357 _unknown_recurring, 331 358 _import_linkable, 332 359 _name_variant, 360 + _candidate_pair_review, 333 361 _low_confidence_review, 334 362 ]: 335 363 if len(suggestions) >= limit: ··· 375 403 f'"{suggestion["source"]["name"]}" ' 376 404 f'\u2194 "{suggestion["target"]["name"]}" ' 377 405 f"(similarity: {suggestion['similarity']:.2f})" 406 + ) 407 + elif suggestion_type == "speaker_candidate_pair": 408 + lines.append( 409 + "Speaker candidate pair: " 410 + f"similarity {suggestion['similarity']:.2f} " 411 + f"({suggestion['source_intervals']} vs " 412 + f"{suggestion['target_intervals']} intervals)" 378 413 ) 379 414 elif suggestion_type == "low_confidence_review": 380 415 seg_info = suggestion.get("segment_key", "")
+32
solstone/apps/speakers/tests/test_suggest.py
··· 13 13 format_suggestions, 14 14 suggest_opportunities, 15 15 ) 16 + from solstone.think.speaker_candidate_pair_review_candidates import ( 17 + record_candidate_pair, 18 + ) 16 19 17 20 18 21 def create_meetings_md(env, day: str, content: str) -> Path: ··· 131 134 assert suggestion["target"] == {"id": "alice_test", "name": "Alice Test"} 132 135 assert suggestion["similarity"] > 0.90 133 136 assert suggestion["readiness"] == "ready" 137 + 138 + 139 + def test_suggest_speaker_candidate_pair_and_formats_it(speakers_env): 140 + speakers_env() 141 + anchor_a = '["20260101","090000_300","test","mic_audio",1]' 142 + anchor_b = '["20260102","090000_300","test","mic_audio",2]' 143 + record_candidate_pair( 144 + source_anchor=anchor_a, 145 + target_anchor=anchor_b, 146 + source_anchors={anchor_a}, 147 + target_anchors={anchor_b}, 148 + similarity=0.62, 149 + source_intervals=31, 150 + target_intervals=35, 151 + source_samples=[], 152 + target_samples=[], 153 + ) 154 + 155 + results = suggest_opportunities() 156 + suggestion = next( 157 + item for item in results if item["type"] == "speaker_candidate_pair" 158 + ) 159 + rendered = format_suggestions([suggestion]) 160 + 161 + assert suggestion["similarity"] == 0.62 162 + assert suggestion["source_intervals"] == 31 163 + assert suggestion["target_intervals"] == 35 164 + assert rendered 165 + assert "Speaker candidate pair: similarity 0.62 (31 vs 35 intervals)" in rendered 134 166 135 167 136 168 def test_suggest_import_linkable(speakers_env):
+145 -1
solstone/think/curation.py
··· 10 10 from typing import Any, Callable 11 11 12 12 import solstone.think.facet_review_candidates as facet_store 13 + from solstone.think import ( 14 + speaker_candidate_pair_review_candidates as speaker_pair_store, 15 + ) 13 16 from solstone.think import speaker_review_candidates as speaker_store 14 17 from solstone.think.entities import review_candidates as entity_store 15 18 from solstone.think.entities.ambiguities import load_ambiguities ··· 22 25 KIND_ENTITY_MERGE = "entity_merge" 23 26 KIND_ENTITY_AMBIGUITY = "entity_ambiguity" 24 27 KIND_SPEAKER_NAME_VARIANT = "speaker_name_variant" 28 + KIND_SPEAKER_CANDIDATE_PAIR = "speaker_candidate_pair" 25 29 NEIGHBORHOOD_WEIGHT = 0.25 26 30 # Entity detection strength is an integer; a sub-1.0 neighborhood contribution 27 31 # can break ties but cannot outrank a genuinely stronger detection count. ··· 87 91 return speaker_store.candidate_key(source_id, target_id) 88 92 89 93 94 + def _speaker_candidate_pair_key(anchor_a: str, anchor_b: str) -> str: 95 + return speaker_pair_store.candidate_key(anchor_a, anchor_b) 96 + 97 + 90 98 def _find_facet_candidate(name_key: str) -> dict[str, Any] | None: 91 99 return facet_store.find_candidate(facet_store.load_candidates(), name_key) 92 100 ··· 115 123 ) 116 124 117 125 126 + def _find_speaker_candidate_pair( 127 + anchor_a: str, 128 + anchor_b: str, 129 + ) -> dict[str, Any] | None: 130 + return speaker_pair_store.find_candidate( 131 + speaker_pair_store.load_candidates(), 132 + anchor_a, 133 + anchor_b, 134 + ) 135 + 136 + 118 137 def _speaker_direction_matches( 119 138 row: dict[str, Any], 120 139 source_id: str, ··· 174 193 } 175 194 176 195 196 + def _speaker_candidate_pair_error( 197 + anchor_a: str, 198 + anchor_b: str, 199 + message: str, 200 + ) -> dict[str, Any]: 201 + return { 202 + "status": "error", 203 + "kind": KIND_SPEAKER_CANDIDATE_PAIR, 204 + "key": _speaker_candidate_pair_key(anchor_a, anchor_b), 205 + "error": message, 206 + } 207 + 208 + 177 209 def _merge_error_context(result: dict[str, Any]) -> dict[str, Any]: 178 210 error = result.get("error") 179 211 nested_repair = isinstance(error, dict) and error.get("code") == "repair_required" ··· 199 231 return str(error) 200 232 201 233 202 - def _undo_descriptor(merge_id: Any) -> dict[str, Any]: 234 + def _undo_descriptor( 235 + merge_id: Any, 236 + *, 237 + kind: str | None = None, 238 + ) -> dict[str, Any]: 239 + if kind == KIND_SPEAKER_CANDIDATE_PAIR: 240 + return { 241 + "available": False, 242 + "merge_id": None, 243 + "reason": "Speaker candidate-pair merges cannot be undone.", 244 + } 203 245 value = str(merge_id or "") 204 246 return { 205 247 "available": bool(value), ··· 336 378 target=str(row.get("target_label") or target_id), 337 379 target_slug=target_id, 338 380 evidence=speaker_evidence, 381 + strength=int(round(similarity * 100)), 382 + composite=float(int(round(similarity * 100))), 383 + ) 384 + ) 385 + 386 + for row in speaker_pair_store.load_candidates(): 387 + if row.get("status") != "open": 388 + continue 389 + evidence = row.get("evidence", {}) 390 + if not isinstance(evidence, dict): 391 + evidence = {} 392 + anchor_a = str(row.get("anchor_a") or "") 393 + anchor_b = str(row.get("anchor_b") or "") 394 + similarity = float(row.get("similarity") or evidence.get("similarity") or 0.0) 395 + pair_evidence = dict(evidence) 396 + pair_evidence["similarity"] = similarity 397 + items.append( 398 + CurationItem( 399 + kind=KIND_SPEAKER_CANDIDATE_PAIR, 400 + key=_speaker_candidate_pair_key(anchor_a, anchor_b), 401 + name=None, 402 + facet=None, 403 + source="candidate A", 404 + source_slug=anchor_a, 405 + target="candidate B", 406 + target_slug=anchor_b, 407 + evidence=pair_evidence, 339 408 strength=int(round(similarity * 100)), 340 409 composite=float(int(round(similarity * 100))), 341 410 ) ··· 735 804 return { 736 805 "status": "dismissed", 737 806 "kind": KIND_SPEAKER_NAME_VARIANT, 807 + "key": key, 808 + "candidate": dismissed, 809 + } 810 + 811 + 812 + def accept_speaker_candidate_pair(anchor_a: str, anchor_b: str) -> dict[str, Any]: 813 + """Accept one open speaker candidate-pair merge candidate.""" 814 + key = _speaker_candidate_pair_key(anchor_a, anchor_b) 815 + row = _find_speaker_candidate_pair(anchor_a, anchor_b) 816 + if row is None: 817 + return _speaker_candidate_pair_error(anchor_a, anchor_b, "candidate not found") 818 + 819 + status = _status(row) 820 + if status == "accepted": 821 + return { 822 + "status": "already_accepted", 823 + "kind": KIND_SPEAKER_CANDIDATE_PAIR, 824 + "key": key, 825 + "candidate": row, 826 + "undo": _undo_descriptor(None, kind=KIND_SPEAKER_CANDIDATE_PAIR), 827 + } 828 + if status != "open": 829 + return _speaker_candidate_pair_error( 830 + anchor_a, 831 + anchor_b, 832 + f"cannot accept candidate with status {status}", 833 + ) 834 + 835 + from solstone.apps.speakers.candidate_tracker import CandidateTracker 836 + 837 + merge = CandidateTracker().merge_candidate_pair(anchor_a, anchor_b) 838 + if merge.get("status") != "merged": 839 + return _speaker_candidate_pair_error( 840 + anchor_a, 841 + anchor_b, 842 + str(merge.get("error") or "candidate pair is already merged"), 843 + ) 844 + 845 + accepted = speaker_pair_store.accept_candidate(anchor_a, anchor_b) 846 + return { 847 + "status": "accepted", 848 + "kind": KIND_SPEAKER_CANDIDATE_PAIR, 849 + "key": key, 850 + "merge": merge, 851 + "candidate": accepted, 852 + "undo": _undo_descriptor(None, kind=KIND_SPEAKER_CANDIDATE_PAIR), 853 + } 854 + 855 + 856 + def dismiss_speaker_candidate_pair(anchor_a: str, anchor_b: str) -> dict[str, Any]: 857 + """Dismiss one open speaker candidate-pair merge candidate.""" 858 + key = _speaker_candidate_pair_key(anchor_a, anchor_b) 859 + row = _find_speaker_candidate_pair(anchor_a, anchor_b) 860 + if row is None: 861 + return _speaker_candidate_pair_error(anchor_a, anchor_b, "candidate not found") 862 + 863 + status = _status(row) 864 + if status == "dismissed": 865 + return { 866 + "status": "already_dismissed", 867 + "kind": KIND_SPEAKER_CANDIDATE_PAIR, 868 + "key": key, 869 + "candidate": row, 870 + } 871 + if status != "open": 872 + return _speaker_candidate_pair_error( 873 + anchor_a, 874 + anchor_b, 875 + f"cannot dismiss candidate with status {status}", 876 + ) 877 + 878 + dismissed = speaker_pair_store.dismiss_candidate(anchor_a, anchor_b) 879 + return { 880 + "status": "dismissed", 881 + "kind": KIND_SPEAKER_CANDIDATE_PAIR, 738 882 "key": key, 739 883 "candidate": dismissed, 740 884 }
+1 -1
solstone/think/speaker_candidate_pair_review_candidates.py
··· 169 169 return False 170 170 171 171 172 - def record_candidate_pair_candidate( 172 + def record_candidate_pair( 173 173 *, 174 174 source_anchor: str, 175 175 target_anchor: str,
+200
tests/test_curation.py
··· 6 6 from pathlib import Path 7 7 from typing import Any 8 8 9 + import numpy as np 9 10 import pytest 10 11 11 12 import solstone.think.curation as curation 13 + from solstone.apps.speakers.candidate_tracker import ( 14 + CandidateProfile, 15 + CandidateTracker, 16 + canonical_candidate_anchor, 17 + ) 12 18 from solstone.think.curation import ( 13 19 KIND_ENTITY_AMBIGUITY, 20 + KIND_SPEAKER_CANDIDATE_PAIR, 14 21 KIND_SPEAKER_NAME_VARIANT, 15 22 accept_entity_candidate, 16 23 accept_entity_candidate_batch, 17 24 accept_facet_candidate, 25 + accept_speaker_candidate_pair, 18 26 dismiss_entity_candidate, 19 27 dismiss_entity_candidate_batch, 20 28 dismiss_facet_candidate, 29 + dismiss_speaker_candidate_pair, 21 30 load_open_items, 22 31 merge_preview_fields, 23 32 ) ··· 44 53 from solstone.think.indexer.edges import insert_edges 45 54 from solstone.think.indexer.journal import get_journal_index 46 55 from solstone.think.journal_io import LockTimeout 56 + from solstone.think.speaker_candidate_pair_review_candidates import ( 57 + load_candidates as load_pair_candidates, 58 + ) 59 + from solstone.think.speaker_candidate_pair_review_candidates import ( 60 + record_candidate_pair, 61 + ) 47 62 from solstone.think.speaker_review_candidates import record_name_variant_candidate 48 63 49 64 ··· 202 217 assert source_name in target["aka"] 203 218 204 219 220 + def _unit(vector: list[float]) -> np.ndarray: 221 + embedding = np.array(vector + [0.0] * (256 - len(vector)), dtype=np.float32) 222 + return embedding / np.linalg.norm(embedding) 223 + 224 + 225 + def _source_segment(day: str, cluster_label: int = 1) -> dict[str, object]: 226 + return { 227 + "day": day, 228 + "segment_key": "090000_300", 229 + "stream": "test", 230 + "source": "mic_audio", 231 + "cluster_label": cluster_label, 232 + } 233 + 234 + 235 + def _candidate_profile( 236 + cand_id: int, 237 + centroid: np.ndarray, 238 + *, 239 + status: str = "pending", 240 + confirmed_entity: str | None = None, 241 + ) -> CandidateProfile: 242 + source_segment = _source_segment(f"2026010{cand_id}", cand_id) 243 + return CandidateProfile( 244 + cand_id=cand_id, 245 + centroid=centroid, 246 + n_segments=1, 247 + n_intervals=30, 248 + total_duration_s=30.0, 249 + source_segments=[source_segment], 250 + status=status, 251 + confirmed_entity=confirmed_entity, 252 + ) 253 + 254 + 255 + def _seed_candidate_tracker( 256 + journal: Path, 257 + candidates: list[CandidateProfile], 258 + ) -> CandidateTracker: 259 + tracker = CandidateTracker(journal / "awareness" / "speaker_candidates.json") 260 + tracker._candidates = {candidate.cand_id: candidate for candidate in candidates} 261 + tracker._next_id = ( 262 + max((candidate.cand_id for candidate in candidates), default=0) + 1 263 + ) 264 + tracker.save() 265 + return CandidateTracker(journal / "awareness" / "speaker_candidates.json") 266 + 267 + 268 + def _record_pair_for_candidates( 269 + left: CandidateProfile, 270 + right: CandidateProfile, 271 + *, 272 + similarity: float = 0.70, 273 + ) -> tuple[str, str]: 274 + anchor_a = canonical_candidate_anchor(left) 275 + anchor_b = canonical_candidate_anchor(right) 276 + record_candidate_pair( 277 + source_anchor=anchor_a, 278 + target_anchor=anchor_b, 279 + source_anchors={anchor_a}, 280 + target_anchors={anchor_b}, 281 + similarity=similarity, 282 + source_intervals=left.n_intervals, 283 + target_intervals=right.n_intervals, 284 + source_samples=[], 285 + target_samples=[], 286 + ) 287 + return anchor_a, anchor_b 288 + 289 + 205 290 def test_load_open_items_normalizes_and_orders(curation_journal): 206 291 save_facet_candidates( 207 292 [ ··· 256 341 assert item.evidence["similarity"] == 0.934 257 342 assert item.evidence["readiness"] == "ready" 258 343 assert item.strength == 93 344 + 345 + 346 + def test_load_open_items_includes_speaker_candidate_pair(curation_journal): 347 + left = _candidate_profile(1, _unit([1.0, 0.0])) 348 + right = _candidate_profile(2, _unit([0.62, np.sqrt(1.0 - 0.62**2)])) 349 + anchor_a, anchor_b = _record_pair_for_candidates(left, right, similarity=0.62) 350 + 351 + items = load_open_items() 352 + 353 + assert len(items) == 1 354 + item = items[0] 355 + assert item.kind == KIND_SPEAKER_CANDIDATE_PAIR 356 + assert item.key == curation._speaker_candidate_pair_key(anchor_a, anchor_b) 357 + assert item.source_slug == anchor_a 358 + assert item.target_slug == anchor_b 359 + assert item.source == "candidate A" 360 + assert item.target == "candidate B" 361 + assert item.evidence["similarity"] == 0.62 362 + assert item.evidence["source_intervals"] == 30 363 + assert item.evidence["target_intervals"] == 30 364 + assert item.strength == 62 259 365 260 366 261 367 def test_entity_merge_equal_strength_uses_shared_neighborhood(curation_journal): ··· 880 986 "already_dismissed", 881 987 "dismissed", 882 988 ] 989 + 990 + 991 + def test_accept_speaker_candidate_pair_merges_tracker_without_entity_merge( 992 + curation_journal, 993 + monkeypatch, 994 + ): 995 + calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] 996 + 997 + def merge_entity_spy(*args: Any, **kwargs: Any) -> dict[str, Any]: 998 + calls.append((args, kwargs)) 999 + raise AssertionError("speaker candidate-pair accept must not merge entities") 1000 + 1001 + monkeypatch.setattr(curation, "merge_entity", merge_entity_spy) 1002 + left = _candidate_profile(1, _unit([1.0, 0.0])) 1003 + right = _candidate_profile( 1004 + 2, 1005 + _unit([0.70, np.sqrt(1.0 - 0.70**2)]), 1006 + status="confirmed", 1007 + confirmed_entity="alice_test", 1008 + ) 1009 + tracker = _seed_candidate_tracker(curation_journal, [left, right]) 1010 + anchor_a, anchor_b = _record_pair_for_candidates( 1011 + tracker.load_all_candidates()[0], 1012 + tracker.load_all_candidates()[1], 1013 + ) 1014 + 1015 + result = accept_speaker_candidate_pair(anchor_b, anchor_a) 1016 + 1017 + assert calls == [] 1018 + assert result["status"] == "accepted" 1019 + assert result["kind"] == KIND_SPEAKER_CANDIDATE_PAIR 1020 + assert result["merge"]["status"] == "merged" 1021 + assert result["undo"] == { 1022 + "available": False, 1023 + "merge_id": None, 1024 + "reason": "Speaker candidate-pair merges cannot be undone.", 1025 + } 1026 + survivor = CandidateTracker( 1027 + curation_journal / "awareness" / "speaker_candidates.json" 1028 + ).load_all_candidates() 1029 + assert len(survivor) == 1 1030 + assert survivor[0].cand_id == 1 1031 + assert survivor[0].status == "confirmed" 1032 + assert survivor[0].confirmed_entity == "alice_test" 1033 + assert load_pair_candidates()[0]["status"] == "accepted" 1034 + 1035 + 1036 + def test_accept_speaker_candidate_pair_stale_tracker_error_keeps_row_open( 1037 + curation_journal, 1038 + monkeypatch, 1039 + ): 1040 + calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] 1041 + monkeypatch.setattr( 1042 + curation, 1043 + "merge_entity", 1044 + lambda *args, **kwargs: calls.append((args, kwargs)), 1045 + ) 1046 + left = _candidate_profile(1, _unit([1.0, 0.0])) 1047 + right = _candidate_profile(2, _unit([0.70, np.sqrt(1.0 - 0.70**2)])) 1048 + anchor_a, anchor_b = _record_pair_for_candidates(left, right) 1049 + 1050 + result = accept_speaker_candidate_pair(anchor_a, anchor_b) 1051 + 1052 + assert calls == [] 1053 + assert result == { 1054 + "status": "error", 1055 + "kind": KIND_SPEAKER_CANDIDATE_PAIR, 1056 + "key": curation._speaker_candidate_pair_key(anchor_a, anchor_b), 1057 + "error": "candidate anchor not found", 1058 + } 1059 + assert load_pair_candidates()[0]["status"] == "open" 1060 + assert ( 1061 + CandidateTracker( 1062 + curation_journal / "awareness" / "speaker_candidates.json" 1063 + ).load_all_candidates() 1064 + == [] 1065 + ) 1066 + 1067 + 1068 + def test_dismiss_speaker_candidate_pair_sets_status_and_removes_open_item( 1069 + curation_journal, 1070 + ): 1071 + left = _candidate_profile(1, _unit([1.0, 0.0])) 1072 + right = _candidate_profile(2, _unit([0.62, np.sqrt(1.0 - 0.62**2)])) 1073 + anchor_a, anchor_b = _record_pair_for_candidates(left, right, similarity=0.62) 1074 + 1075 + result = dismiss_speaker_candidate_pair(anchor_b, anchor_a) 1076 + 1077 + assert result["status"] == "dismissed" 1078 + assert result["kind"] == KIND_SPEAKER_CANDIDATE_PAIR 1079 + assert load_pair_candidates()[0]["status"] == "dismissed" 1080 + assert [ 1081 + item for item in load_open_items() if item.kind == KIND_SPEAKER_CANDIDATE_PAIR 1082 + ] == [] 883 1083 884 1084 885 1085 def test_merge_preview_fields_returns_compact_summary():
+7 -9
tests/test_speaker_candidate_pair_review_candidates.py
··· 14 14 find_candidate, 15 15 is_dismissed_pair_suppressed, 16 16 load_candidates, 17 - record_candidate_pair_candidate, 17 + record_candidate_pair, 18 18 review_candidates_path, 19 19 ) 20 20 ··· 39 39 assert candidate_key(anchor_a, anchor_b) == candidate_key(anchor_b, anchor_a) 40 40 41 41 42 - def test_record_candidate_pair_candidate_creates_evidence_row( 43 - candidate_journal, monkeypatch 44 - ): 42 + def test_record_candidate_pair_creates_evidence_row(candidate_journal, monkeypatch): 45 43 monkeypatch.setattr(mod, "utc_now_iso", lambda: "2026-06-03T17:30:00Z") 46 44 anchor_a = '["20260101","090000_300","test","mic_audio",1]' 47 45 anchor_b = '["20260102","090000_300","test","mic_audio",2]' ··· 54 52 "audio_url": "/app/speakers/api/serve_audio/20260101/test/090000_300/mic_audio.flac", 55 53 } 56 54 57 - row, created, suppressed = record_candidate_pair_candidate( 55 + row, created, suppressed = record_candidate_pair( 58 56 source_anchor=anchor_b, 59 57 target_anchor=anchor_a, 60 58 source_anchors={anchor_b}, ··· 100 98 anchor_b = '["20260102","090000_300","test","mic_audio",1]' 101 99 anchor_c = '["20260103","090000_300","test","mic_audio",1]' 102 100 103 - record_candidate_pair_candidate( 101 + record_candidate_pair( 104 102 source_anchor=anchor_a, 105 103 target_anchor=anchor_b, 106 104 source_anchors={anchor_a}, ··· 117 115 rows = load_candidates() 118 116 assert is_dismissed_pair_suppressed(rows, {anchor_a, anchor_c}, {anchor_b}) 119 117 assert is_dismissed_pair_suppressed(rows, {anchor_b}, {anchor_a, anchor_c}) 120 - row, created, suppressed = record_candidate_pair_candidate( 118 + row, created, suppressed = record_candidate_pair( 121 119 source_anchor=anchor_c, 122 120 target_anchor=anchor_b, 123 121 source_anchors={anchor_a, anchor_c}, ··· 139 137 def test_dismissed_rows_do_not_reopen(candidate_journal): 140 138 anchor_a = '["20260101","090000_300","test","mic_audio",1]' 141 139 anchor_b = '["20260102","090000_300","test","mic_audio",1]' 142 - record_candidate_pair_candidate( 140 + record_candidate_pair( 143 141 source_anchor=anchor_a, 144 142 target_anchor=anchor_b, 145 143 source_anchors={anchor_a}, ··· 152 150 ) 153 151 dismiss_candidate(anchor_a, anchor_b) 154 152 155 - row, created, suppressed = record_candidate_pair_candidate( 153 + row, created, suppressed = record_candidate_pair( 156 154 source_anchor=anchor_a, 157 155 target_anchor=anchor_b, 158 156 source_anchors={anchor_a},