personal memory agent
0

Configure Feed

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

solstone / tests / test_curation.py
36 kB 1182 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4from __future__ import annotations 5 6from pathlib import Path 7from typing import Any 8 9import numpy as np 10import pytest 11 12import solstone.think.curation as curation 13from solstone.apps.speakers.candidate_tracker import ( 14 CandidateProfile, 15 CandidateTracker, 16 canonical_candidate_anchor, 17) 18from solstone.think.curation import ( 19 KIND_ENTITY_AMBIGUITY, 20 KIND_SPEAKER_CANDIDATE_PAIR, 21 KIND_SPEAKER_NAME_VARIANT, 22 accept_entity_candidate, 23 accept_entity_candidate_batch, 24 accept_facet_candidate, 25 accept_speaker_candidate_pair, 26 dismiss_entity_candidate, 27 dismiss_entity_candidate_batch, 28 dismiss_facet_candidate, 29 dismiss_speaker_candidate_pair, 30 load_open_items, 31 merge_preview_fields, 32) 33from solstone.think.entities import ( 34 EntityResolutionOutcome, 35 ResolutionOrigin, 36 ResolutionScope, 37 record_entity_resolution, 38 undo_entity_merge, 39) 40from solstone.think.entities.journal import load_journal_entity, save_journal_entity 41from solstone.think.entities.review_candidates import ( 42 load_candidates as load_entity_candidates, 43) 44from solstone.think.entities.review_candidates import ( 45 save_candidates as save_entity_candidates, 46) 47from solstone.think.facet_review_candidates import ( 48 load_candidates as load_facet_candidates, 49) 50from solstone.think.facet_review_candidates import ( 51 save_candidates as save_facet_candidates, 52) 53from solstone.think.indexer.edges import insert_edges 54from solstone.think.indexer.journal import get_journal_index 55from solstone.think.journal_io import LockTimeout 56from solstone.think.speaker_candidate_pair_review_candidates import ( 57 load_candidates as load_pair_candidates, 58) 59from solstone.think.speaker_candidate_pair_review_candidates import ( 60 record_candidate_pair, 61) 62from solstone.think.speaker_keep_separate import ( 63 pair_key, 64 record_keep_separate_assertion, 65 remove_operation_sources, 66) 67from solstone.think.speaker_review_candidates import record_name_variant_candidate 68 69 70@pytest.fixture 71def curation_journal(monkeypatch, tmp_path) -> Path: 72 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 73 import solstone.think.utils as think_utils 74 75 think_utils._journal_path_cache = None 76 return Path(tmp_path) 77 78 79def _seed_entities() -> None: 80 save_journal_entity( 81 { 82 "id": "kognova_inc", 83 "name": "Kognova Inc", 84 "type": "Company", 85 "aka": ["Kognova Incorporated"], 86 } 87 ) 88 save_journal_entity( 89 { 90 "id": "kognova", 91 "name": "Kognova", 92 "type": "Company", 93 "aka": [], 94 } 95 ) 96 97 98def test_open_ambiguity_is_a_ranked_curation_item(curation_journal): 99 save_journal_entity( 100 {"id": "sarah_connor", "name": "Sarah Connor", "type": "Person"} 101 ) 102 save_journal_entity({"id": "sarah_lee", "name": "Sarah Lee", "type": "Person"}) 103 resolution = record_entity_resolution( 104 "Sarah", 105 [load_journal_entity("sarah_connor"), load_journal_entity("sarah_lee")], 106 scope=ResolutionScope.journal(), 107 origin=ResolutionOrigin(lane="test.curation", field="entity"), 108 ) 109 110 assert resolution.outcome == EntityResolutionOutcome.AMBIGUOUS 111 item = next( 112 item for item in load_open_items() if item.kind == KIND_ENTITY_AMBIGUITY 113 ) 114 assert item.key == resolution.ambiguity_id 115 assert item.name == "Sarah" 116 assert {row["id"] for row in item.evidence["ranked_candidates"]} == { 117 "sarah_connor", 118 "sarah_lee", 119 } 120 assert item.evidence["origins"] == [{"lane": "test.curation", "field": "entity"}] 121 122 123def _entity_candidate_row( 124 source_slug: str = "kognova_inc", 125 target_slug: str = "kognova", 126 *, 127 source: str = "Kognova Inc", 128 target: str = "Kognova", 129 status: str = "open", 130 detection_count: int = 4, 131) -> dict[str, Any]: 132 return { 133 "facet": "work", 134 "source": source, 135 "source_slug": source_slug, 136 "target": target, 137 "target_slug": target_slug, 138 "status": status, 139 "evidence": { 140 "basis": "name-variant", 141 "summary": f"{source} / {target}", 142 "detection_count": detection_count, 143 "needs": 0, 144 }, 145 } 146 147 148def _seed_entity_candidates(rows: list[dict[str, Any]]) -> None: 149 save_entity_candidates(rows) 150 151 152def _insert_edges(journal: Path, rows: list[dict[str, Any]]) -> None: 153 conn, _ = get_journal_index(str(journal)) 154 try: 155 insert_edges(conn, rows) 156 conn.commit() 157 finally: 158 conn.close() 159 160 161def _edge( 162 src: str, 163 dst: str, 164 path: str, 165 *, 166 day: str | None = "20260601", 167) -> dict[str, Any]: 168 return { 169 "src": src, 170 "dst": dst, 171 "kind": "works-with", 172 "day": day, 173 "facet": "work", 174 "source": "curation-test", 175 "path": path, 176 "weight": 1, 177 } 178 179 180def _tree_snapshot(root: Path) -> dict[str, bytes]: 181 return { 182 str(path.relative_to(root)): path.read_bytes() 183 for path in sorted(root.rglob("*")) 184 if path.is_file() 185 } 186 187 188def _seed_entity_candidate(status: str = "open", detection_count: int = 4) -> None: 189 _seed_entity_candidates( 190 [_entity_candidate_row(status=status, detection_count=detection_count)] 191 ) 192 193 194def _seed_entity_pair( 195 source_slug: str, 196 source_name: str, 197 target_slug: str, 198 target_name: str, 199) -> None: 200 save_journal_entity( 201 { 202 "id": source_slug, 203 "name": source_name, 204 "type": "Company", 205 "aka": [], 206 } 207 ) 208 save_journal_entity( 209 { 210 "id": target_slug, 211 "name": target_name, 212 "type": "Company", 213 "aka": [], 214 } 215 ) 216 217 218def _assert_folded(source_slug: str, target_slug: str, source_name: str) -> None: 219 assert load_journal_entity(source_slug) is None 220 target = load_journal_entity(target_slug) 221 assert target is not None 222 assert source_name in target["aka"] 223 224 225def _unit(vector: list[float]) -> np.ndarray: 226 embedding = np.array(vector + [0.0] * (256 - len(vector)), dtype=np.float32) 227 return embedding / np.linalg.norm(embedding) 228 229 230def _source_segment(day: str, cluster_label: int = 1) -> dict[str, object]: 231 return { 232 "day": day, 233 "segment_key": "090000_300", 234 "stream": "test", 235 "source": "mic_audio", 236 "cluster_label": cluster_label, 237 } 238 239 240def _candidate_profile( 241 cand_id: int, 242 centroid: np.ndarray, 243 *, 244 status: str = "pending", 245 confirmed_entity: str | None = None, 246) -> CandidateProfile: 247 source_segment = _source_segment(f"2026010{cand_id}", cand_id) 248 return CandidateProfile( 249 cand_id=cand_id, 250 centroid=centroid, 251 n_segments=1, 252 n_intervals=30, 253 total_duration_s=30.0, 254 source_segments=[source_segment], 255 status=status, 256 confirmed_entity=confirmed_entity, 257 ) 258 259 260def _seed_candidate_tracker( 261 journal: Path, 262 candidates: list[CandidateProfile], 263) -> CandidateTracker: 264 tracker = CandidateTracker(journal / "awareness" / "speaker_candidates.json") 265 tracker._candidates = {candidate.cand_id: candidate for candidate in candidates} 266 tracker._next_id = ( 267 max((candidate.cand_id for candidate in candidates), default=0) + 1 268 ) 269 tracker.save() 270 return CandidateTracker(journal / "awareness" / "speaker_candidates.json") 271 272 273def _record_pair_for_candidates( 274 left: CandidateProfile, 275 right: CandidateProfile, 276 *, 277 similarity: float = 0.70, 278) -> tuple[str, str]: 279 anchor_a = canonical_candidate_anchor(left) 280 anchor_b = canonical_candidate_anchor(right) 281 record_candidate_pair( 282 source_anchor=anchor_a, 283 target_anchor=anchor_b, 284 source_anchors={anchor_a}, 285 target_anchors={anchor_b}, 286 similarity=similarity, 287 source_intervals=left.n_intervals, 288 target_intervals=right.n_intervals, 289 source_samples=[], 290 target_samples=[], 291 ) 292 return anchor_a, anchor_b 293 294 295def test_load_open_items_normalizes_and_orders(curation_journal): 296 save_facet_candidates( 297 [ 298 { 299 "name": "Home Reno", 300 "name_key": "home reno", 301 "status": "open", 302 "count": 3, 303 "window_days": 14, 304 "evidence": {"samples": [{"day": "20260602"}]}, 305 }, 306 { 307 "name": "Done", 308 "name_key": "done", 309 "status": "dismissed", 310 "count": 9, 311 }, 312 ] 313 ) 314 _seed_entity_candidate(detection_count=5) 315 316 items = load_open_items() 317 318 assert [item.key for item in items] == ["work|kognova_inc|kognova", "home reno"] 319 assert items[0].kind == "entity_merge" 320 assert items[0].strength == 5 321 assert items[1].kind == "facet_candidate" 322 assert items[1].evidence["count"] == 3 323 324 325def test_load_open_items_includes_speaker_name_variant(curation_journal): 326 record_name_variant_candidate( 327 source_id="alice", 328 source_label="Alice", 329 target_id="alice_johnson", 330 target_label="Alice Johnson", 331 similarity=0.934, 332 ) 333 334 items = load_open_items() 335 336 assert len(items) == 1 337 item = items[0] 338 assert item.kind == KIND_SPEAKER_NAME_VARIANT 339 assert item.key == "alice|alice_johnson" 340 assert item.name is None 341 assert item.facet is None 342 assert item.source == "Alice" 343 assert item.source_slug == "alice" 344 assert item.target == "Alice Johnson" 345 assert item.target_slug == "alice_johnson" 346 assert item.evidence["similarity"] == 0.934 347 assert item.evidence["readiness"] == "ready" 348 assert item.strength == 93 349 350 351def test_load_open_items_filters_keep_separate_speaker_name_variant(curation_journal): 352 record_name_variant_candidate( 353 source_id="alice", 354 source_label="Alice", 355 target_id="alice_johnson", 356 target_label="Alice Johnson", 357 similarity=0.934, 358 ) 359 record_keep_separate_assertion( 360 "alice", 361 "alice_johnson", 362 source_kind="explicit_create_near_match", 363 operation_id="idop_test", 364 detection_count=1, 365 ) 366 367 items = load_open_items() 368 369 assert [item for item in items if item.kind == KIND_SPEAKER_NAME_VARIANT] == [] 370 371 372def test_load_open_items_resurfaces_expired_keep_separate_suppression( 373 curation_journal, 374): 375 operation_id = "idop_test" 376 record_keep_separate_assertion( 377 "alice", 378 "alice_johnson", 379 source_kind="explicit_create_near_match", 380 operation_id=operation_id, 381 detection_count=1, 382 ) 383 row, created, suppressed = record_name_variant_candidate( 384 source_id="alice", 385 source_label="Alice", 386 target_id="alice_johnson", 387 target_label="Alice Johnson", 388 similarity=0.934, 389 ) 390 391 assert created is True 392 assert suppressed is True 393 assert row["status"] == "suppressed" 394 assert row["suppressed_by_keep_separate"] is True 395 assert [ 396 item for item in load_open_items() if item.kind == KIND_SPEAKER_NAME_VARIANT 397 ] == [] 398 399 remove_operation_sources(operation_id, [pair_key("alice", "alice_johnson")]) 400 401 items = [ 402 item for item in load_open_items() if item.kind == KIND_SPEAKER_NAME_VARIANT 403 ] 404 assert len(items) == 1 405 assert items[0].key == "alice|alice_johnson" 406 407 408def test_load_open_items_includes_speaker_candidate_pair(curation_journal): 409 left = _candidate_profile(1, _unit([1.0, 0.0])) 410 right = _candidate_profile(2, _unit([0.62, np.sqrt(1.0 - 0.62**2)])) 411 anchor_a, anchor_b = _record_pair_for_candidates(left, right, similarity=0.62) 412 413 items = load_open_items() 414 415 assert len(items) == 1 416 item = items[0] 417 assert item.kind == KIND_SPEAKER_CANDIDATE_PAIR 418 assert item.key == curation._speaker_candidate_pair_key(anchor_a, anchor_b) 419 assert item.source_slug == anchor_a 420 assert item.target_slug == anchor_b 421 assert item.source == "candidate A" 422 assert item.target == "candidate B" 423 assert item.evidence["similarity"] == 0.62 424 assert item.evidence["source_intervals"] == 30 425 assert item.evidence["target_intervals"] == 30 426 assert item.strength == 62 427 428 429def test_entity_merge_equal_strength_uses_shared_neighborhood(curation_journal): 430 _seed_entity_candidates( 431 [ 432 _entity_candidate_row( 433 source_slug="alpha_source", 434 target_slug="alpha_target", 435 source="Alpha Source", 436 target="Alpha Target", 437 detection_count=5, 438 ), 439 _entity_candidate_row( 440 source_slug="zeta_source", 441 target_slug="zeta_target", 442 source="Zeta Source", 443 target="Zeta Target", 444 detection_count=5, 445 ), 446 ] 447 ) 448 _insert_edges( 449 curation_journal, 450 [ 451 _edge("zeta_source", "shared_one", "curation/zeta-shared-one-a"), 452 _edge("zeta_target", "shared_one", "curation/zeta-shared-one-b"), 453 _edge("zeta_source", "shared_two", "curation/zeta-shared-two-a"), 454 _edge("zeta_target", "shared_two", "curation/zeta-shared-two-b"), 455 _edge("zeta_source", "source_only", "curation/zeta-source-only"), 456 _edge("zeta_target", "target_only", "curation/zeta-target-only"), 457 ], 458 ) 459 460 items = load_open_items() 461 462 assert [item.key for item in items] == [ 463 "work|zeta_source|zeta_target", 464 "work|alpha_source|alpha_target", 465 ] 466 assert items[0].strength == 5 467 assert items[0].evidence["shared_neighbors"] == ["shared_one", "shared_two"] 468 assert items[0].evidence["neighborhood_similarity"] == pytest.approx(0.5) 469 assert items[0].evidence["composite"] == pytest.approx(5.125) 470 assert items[0].composite == pytest.approx(5.125) 471 assert items[0].to_dict()["composite"] == pytest.approx(5.125) 472 473 474def test_entity_merge_detection_strength_stays_primary(curation_journal): 475 _seed_entity_candidates( 476 [ 477 _entity_candidate_row( 478 source_slug="alpha_high", 479 target_slug="alpha_target", 480 source="Alpha High", 481 target="Alpha Target", 482 detection_count=6, 483 ), 484 _entity_candidate_row( 485 source_slug="zeta_low", 486 target_slug="zeta_target", 487 source="Zeta Low", 488 target="Zeta Target", 489 detection_count=5, 490 ), 491 ] 492 ) 493 _insert_edges( 494 curation_journal, 495 [ 496 _edge("zeta_low", "shared_one", "curation/low-shared-one-a"), 497 _edge("zeta_target", "shared_one", "curation/low-shared-one-b"), 498 _edge("zeta_low", "shared_two", "curation/low-shared-two-a"), 499 _edge("zeta_target", "shared_two", "curation/low-shared-two-b"), 500 ], 501 ) 502 503 items = load_open_items() 504 505 assert [item.key for item in items] == [ 506 "work|alpha_high|alpha_target", 507 "work|zeta_low|zeta_target", 508 ] 509 assert items[0].strength == 6 510 assert items[0].composite == 6.0 511 assert items[1].strength == 5 512 assert items[1].composite == pytest.approx(5.25) 513 514 515def test_load_open_items_missing_edge_index_degrades_without_mutation( 516 curation_journal, 517): 518 _seed_entity_candidates( 519 [ 520 _entity_candidate_row( 521 source_slug="zeta_source", 522 target_slug="zeta_target", 523 source="Zeta Source", 524 target="Zeta Target", 525 detection_count=3, 526 ), 527 _entity_candidate_row( 528 source_slug="alpha_source", 529 target_slug="alpha_target", 530 source="Alpha Source", 531 target="Alpha Target", 532 detection_count=3, 533 ), 534 _entity_candidate_row( 535 source_slug="middle_source", 536 target_slug="middle_target", 537 source="Middle Source", 538 target="Middle Target", 539 detection_count=4, 540 ), 541 ] 542 ) 543 before = _tree_snapshot(curation_journal) 544 545 items = load_open_items() 546 547 assert _tree_snapshot(curation_journal) == before 548 assert [item.key for item in items] == [ 549 "work|middle_source|middle_target", 550 "work|alpha_source|alpha_target", 551 "work|zeta_source|zeta_target", 552 ] 553 assert [item.key for item in items] == [ 554 item.key for item in sorted(items, key=lambda item: (-item.strength, item.key)) 555 ] 556 assert [item.composite for item in items] == [4.0, 3.0, 3.0] 557 558 559def test_accept_facet_candidate_creates_facet_then_marks_accepted(curation_journal): 560 save_facet_candidates( 561 [ 562 { 563 "name": "Home Reno", 564 "name_key": "home reno", 565 "status": "open", 566 "count": 3, 567 } 568 ] 569 ) 570 571 result = accept_facet_candidate("home reno") 572 573 assert result["status"] == "accepted" 574 assert result["facet_slug"] == "home-reno" 575 assert (curation_journal / "facets" / "home-reno" / "facet.json").exists() 576 assert load_facet_candidates()[0]["status"] == "accepted" 577 578 579def test_accept_facet_candidate_is_idempotent_when_already_accepted(curation_journal): 580 save_facet_candidates( 581 [ 582 { 583 "name": "Home Reno", 584 "name_key": "home reno", 585 "status": "accepted", 586 "count": 3, 587 } 588 ] 589 ) 590 591 result = accept_facet_candidate("home reno") 592 593 assert result["status"] == "already_accepted" 594 assert not (curation_journal / "facets" / "home-reno").exists() 595 596 597def test_accept_facet_candidate_duplicate_keeps_candidate_open(curation_journal): 598 save_facet_candidates( 599 [ 600 { 601 "name": "Home Reno", 602 "name_key": "home reno", 603 "status": "open", 604 "count": 3, 605 } 606 ] 607 ) 608 first = accept_facet_candidate("home reno") 609 save_facet_candidates( 610 [ 611 { 612 "name": "Home Reno", 613 "name_key": "home reno", 614 "status": "open", 615 "count": 3, 616 } 617 ] 618 ) 619 620 second = accept_facet_candidate("home reno") 621 622 assert first["status"] == "accepted" 623 assert second["status"] == "error" 624 assert "already exists" in second["error"] 625 assert load_facet_candidates()[0]["status"] == "open" 626 627 628def test_dismiss_facet_candidate_sets_watermark_and_is_idempotent(curation_journal): 629 save_facet_candidates( 630 [ 631 { 632 "name": "Home Reno", 633 "name_key": "home reno", 634 "status": "open", 635 "count": 4, 636 } 637 ] 638 ) 639 640 first = dismiss_facet_candidate("home reno") 641 second = dismiss_facet_candidate("home reno") 642 643 assert first["status"] == "dismissed" 644 assert second["status"] == "already_dismissed" 645 assert load_facet_candidates()[0]["dismissed_count"] == 4 646 647 648def test_entity_preview_is_read_only(curation_journal): 649 _seed_entities() 650 _seed_entity_candidate() 651 652 result = accept_entity_candidate( 653 "work", 654 "kognova_inc", 655 "kognova", 656 commit=False, 657 ) 658 659 assert result["status"] == "preview" 660 assert result["merge"]["would_identity"]["akas_added"] == [ 661 "Kognova Inc", 662 "Kognova Incorporated", 663 ] 664 assert load_entity_candidates()[0]["status"] == "open" 665 666 667def test_accept_entity_candidate_commits_then_marks_accepted(curation_journal): 668 _seed_entities() 669 _seed_entity_candidate() 670 671 result = accept_entity_candidate( 672 "work", 673 "kognova_inc", 674 "kognova", 675 commit=True, 676 ) 677 678 assert result["status"] == "accepted" 679 assert result["merge"]["merged"] is True 680 assert load_entity_candidates()[0]["status"] == "accepted" 681 682 683def test_accept_entity_candidate_error_keeps_status_open(curation_journal): 684 _seed_entity_candidate() 685 686 result = accept_entity_candidate( 687 "work", 688 "kognova_inc", 689 "kognova", 690 commit=True, 691 ) 692 693 assert result["status"] == "error" 694 assert "Source entity not found" in result["error"] 695 assert load_entity_candidates()[0]["status"] == "open" 696 697 698def test_repair_required_merge_state_survives_single_and_batch_results( 699 curation_journal, 700 monkeypatch: pytest.MonkeyPatch, 701): 702 _seed_entity_candidate() 703 repair = { 704 "error": {"code": "repair_required", "message": "rollback failed"}, 705 "operation_state": "repair_required", 706 "mutation_applied": True, 707 "source_state": {"exists": True}, 708 "target_state": {"exists": True}, 709 "safe_remediation": "Inspect before retrying.", 710 } 711 monkeypatch.setattr(curation, "merge_entity", lambda *args, **kwargs: repair) 712 713 single = accept_entity_candidate("work", "kognova_inc", "kognova", commit=True) 714 batch = accept_entity_candidate_batch( 715 [ 716 { 717 "facet": "work", 718 "source_slug": "kognova_inc", 719 "target_slug": "kognova", 720 } 721 ] 722 ) 723 724 assert single["error"] == "rollback failed" 725 assert single["operation_state"] == "repair_required" 726 assert single["mutation_applied"] is True 727 assert single["safe_remediation"] == "Inspect before retrying." 728 assert batch["results"][0]["operation_state"] == "repair_required" 729 assert batch["results"][0]["safe_remediation"] == "Inspect before retrying." 730 731 732def test_dismiss_entity_candidate_sets_watermark_and_is_idempotent(curation_journal): 733 _seed_entity_candidate() 734 735 first = dismiss_entity_candidate("work", "kognova_inc", "kognova") 736 second = dismiss_entity_candidate("work", "kognova_inc", "kognova") 737 738 assert first["status"] == "dismissed" 739 assert second["status"] == "already_dismissed" 740 assert load_entity_candidates()[0]["dismissed_detection_count"] == 4 741 742 743def test_accept_entity_candidate_batch_accepts_many_in_order(curation_journal): 744 _seed_entity_pair("kognova_inc", "Kognova Inc", "kognova", "Kognova") 745 _seed_entity_pair("octo_labs_inc", "Octo Labs Inc", "octo_labs", "Octo Labs") 746 _seed_entity_candidates( 747 [ 748 _entity_candidate_row(), 749 _entity_candidate_row( 750 "octo_labs_inc", 751 "octo_labs", 752 source="Octo Labs Inc", 753 target="Octo Labs", 754 detection_count=3, 755 ), 756 ] 757 ) 758 759 result = accept_entity_candidate_batch( 760 [ 761 {"facet": "work", "source_slug": "kognova_inc", "target_slug": "kognova"}, 762 { 763 "facet": "work", 764 "source_slug": "octo_labs_inc", 765 "target_slug": "octo_labs", 766 }, 767 ] 768 ) 769 770 assert result["accepted"] == 2 771 assert result["failed"] == 0 772 assert len(result["results"]) == 2 773 assert [row["source_slug"] for row in result["results"]] == [ 774 "kognova_inc", 775 "octo_labs_inc", 776 ] 777 assert all( 778 set(row) 779 == { 780 "facet", 781 "source_slug", 782 "target_slug", 783 "status", 784 "error", 785 "merge_id", 786 "undo", 787 } 788 for row in result["results"] 789 ) 790 assert all(row["merge_id"].startswith("em_") for row in result["results"]) 791 assert all(row["undo"]["available"] for row in result["results"]) 792 assert all( 793 "merge" not in row and "candidate" not in row for row in result["results"] 794 ) 795 _assert_folded("kognova_inc", "kognova", "Kognova Inc") 796 _assert_folded("octo_labs_inc", "octo_labs", "Octo Labs Inc") 797 798 799def test_batch_merge_undo_one_preserves_later_success_on_same_target( 800 curation_journal, 801): 802 save_journal_entity( 803 { 804 "id": "alpha_inc", 805 "name": "Alpha Inc", 806 "type": "Company", 807 "aka": ["Alpha Alias"], 808 } 809 ) 810 save_journal_entity( 811 { 812 "id": "beta_inc", 813 "name": "Beta Inc", 814 "type": "Company", 815 "aka": ["Beta Alias"], 816 } 817 ) 818 save_journal_entity( 819 {"id": "anchor", "name": "Anchor", "type": "Company", "aka": []} 820 ) 821 _seed_entity_candidates( 822 [ 823 _entity_candidate_row( 824 source_slug="alpha_inc", 825 target_slug="anchor", 826 source="Alpha Inc", 827 target="Anchor", 828 ), 829 _entity_candidate_row( 830 source_slug="beta_inc", 831 target_slug="anchor", 832 source="Beta Inc", 833 target="Anchor", 834 ), 835 ] 836 ) 837 838 batch = accept_entity_candidate_batch( 839 [ 840 { 841 "facet": "work", 842 "source_slug": "alpha_inc", 843 "target_slug": "anchor", 844 }, 845 { 846 "facet": "work", 847 "source_slug": "beta_inc", 848 "target_slug": "anchor", 849 }, 850 ] 851 ) 852 undone = undo_entity_merge(batch["results"][0]["merge_id"], caller="test.curation") 853 854 assert undone["undone"] is True 855 assert load_journal_entity("alpha_inc")["name"] == "Alpha Inc" 856 assert load_journal_entity("beta_inc") is None 857 anchor = load_journal_entity("anchor") 858 assert "Beta Inc" in anchor["aka"] 859 assert "Beta Alias" in anchor["aka"] 860 assert "Alpha Inc" not in anchor["aka"] 861 assert "Alpha Alias" not in anchor["aka"] 862 863 864def test_accept_entity_candidate_batch_malformed_first_continues(curation_journal): 865 _seed_entities() 866 _seed_entity_candidate() 867 868 result = accept_entity_candidate_batch( 869 [ 870 {"facet": "work", "source_slug": "missing_target"}, 871 {"facet": "work", "source_slug": "kognova_inc", "target_slug": "kognova"}, 872 ] 873 ) 874 875 assert result["accepted"] == 1 876 assert result["failed"] == 1 877 assert len(result["results"]) == 2 878 assert result["results"][0] == { 879 "facet": "work", 880 "source_slug": "missing_target", 881 "target_slug": "", 882 "status": "error", 883 "error": "candidate is missing facet, source_slug, or target_slug", 884 } 885 _assert_folded("kognova_inc", "kognova", "Kognova Inc") 886 887 888def test_accept_entity_candidate_batch_lock_timeout_continues( 889 curation_journal, 890 monkeypatch, 891): 892 _seed_entities() 893 _seed_entity_candidates( 894 [ 895 _entity_candidate_row( 896 "busy_inc", 897 "busy", 898 source="Busy Inc", 899 target="Busy", 900 ), 901 _entity_candidate_row(), 902 ] 903 ) 904 real_merge = curation.merge_entity 905 906 def raise_busy_for_one(source_id: str, target_id: str, **kwargs): 907 if source_id == "busy_inc": 908 raise LockTimeout(Path("busy"), 0.01) 909 return real_merge(source_id, target_id, **kwargs) 910 911 monkeypatch.setattr(curation, "merge_entity", raise_busy_for_one) 912 913 result = accept_entity_candidate_batch( 914 [ 915 {"facet": "work", "source_slug": "busy_inc", "target_slug": "busy"}, 916 {"facet": "work", "source_slug": "kognova_inc", "target_slug": "kognova"}, 917 ] 918 ) 919 920 assert result["accepted"] == 1 921 assert result["failed"] == 1 922 assert result["results"][0]["status"] == "error" 923 assert ( 924 result["results"][0]["error"] == "entity merge candidates are busy; try again" 925 ) 926 _assert_folded("kognova_inc", "kognova", "Kognova Inc") 927 928 929def test_accept_entity_candidate_batch_counts_already_accepted(curation_journal): 930 _seed_entities() 931 _seed_entity_candidates( 932 [ 933 _entity_candidate_row( 934 "accepted_inc", 935 "accepted", 936 source="Accepted Inc", 937 target="Accepted", 938 status="accepted", 939 ), 940 _entity_candidate_row(), 941 ] 942 ) 943 944 result = accept_entity_candidate_batch( 945 [ 946 { 947 "facet": "work", 948 "source_slug": "accepted_inc", 949 "target_slug": "accepted", 950 }, 951 {"facet": "work", "source_slug": "kognova_inc", "target_slug": "kognova"}, 952 ] 953 ) 954 955 assert result["accepted"] == 2 956 assert result["failed"] == 0 957 assert [row["status"] for row in result["results"]] == [ 958 "already_accepted", 959 "accepted", 960 ] 961 _assert_folded("kognova_inc", "kognova", "Kognova Inc") 962 963 964def test_dismiss_entity_candidate_batch_dismisses_many(curation_journal): 965 _seed_entity_candidates( 966 [ 967 _entity_candidate_row(), 968 _entity_candidate_row( 969 "octo_labs_inc", 970 "octo_labs", 971 source="Octo Labs Inc", 972 target="Octo Labs", 973 ), 974 ] 975 ) 976 977 result = dismiss_entity_candidate_batch( 978 [ 979 {"facet": "work", "source_slug": "kognova_inc", "target_slug": "kognova"}, 980 { 981 "facet": "work", 982 "source_slug": "octo_labs_inc", 983 "target_slug": "octo_labs", 984 }, 985 ] 986 ) 987 988 assert result["dismissed"] == 2 989 assert result["failed"] == 0 990 assert len(result["results"]) == 2 991 assert all( 992 set(row) == {"facet", "source_slug", "target_slug", "status", "error"} 993 for row in result["results"] 994 ) 995 assert [row["status"] for row in load_entity_candidates()] == [ 996 "dismissed", 997 "dismissed", 998 ] 999 1000 1001def test_dismiss_entity_candidate_batch_malformed_first_continues(curation_journal): 1002 _seed_entity_candidate() 1003 1004 result = dismiss_entity_candidate_batch( 1005 [ 1006 {"facet": "work", "source_slug": "missing_target"}, 1007 {"facet": "work", "source_slug": "kognova_inc", "target_slug": "kognova"}, 1008 ] 1009 ) 1010 1011 assert result["dismissed"] == 1 1012 assert result["failed"] == 1 1013 assert result["results"][0]["status"] == "error" 1014 assert result["results"][0]["error"] == ( 1015 "candidate is missing facet, source_slug, or target_slug" 1016 ) 1017 assert load_entity_candidates()[0]["status"] == "dismissed" 1018 1019 1020def test_dismiss_entity_candidate_batch_counts_already_dismissed(curation_journal): 1021 _seed_entity_candidates( 1022 [ 1023 _entity_candidate_row( 1024 "dismissed_inc", 1025 "dismissed", 1026 source="Dismissed Inc", 1027 target="Dismissed", 1028 status="dismissed", 1029 ), 1030 _entity_candidate_row(), 1031 ] 1032 ) 1033 1034 result = dismiss_entity_candidate_batch( 1035 [ 1036 { 1037 "facet": "work", 1038 "source_slug": "dismissed_inc", 1039 "target_slug": "dismissed", 1040 }, 1041 {"facet": "work", "source_slug": "kognova_inc", "target_slug": "kognova"}, 1042 ] 1043 ) 1044 1045 assert result["dismissed"] == 2 1046 assert result["failed"] == 0 1047 assert [row["status"] for row in result["results"]] == [ 1048 "already_dismissed", 1049 "dismissed", 1050 ] 1051 1052 1053def test_accept_speaker_candidate_pair_merges_tracker_without_entity_merge( 1054 curation_journal, 1055 monkeypatch, 1056): 1057 calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] 1058 1059 def merge_entity_spy(*args: Any, **kwargs: Any) -> dict[str, Any]: 1060 calls.append((args, kwargs)) 1061 raise AssertionError("speaker candidate-pair accept must not merge entities") 1062 1063 monkeypatch.setattr(curation, "merge_entity", merge_entity_spy) 1064 left = _candidate_profile(1, _unit([1.0, 0.0])) 1065 right = _candidate_profile( 1066 2, 1067 _unit([0.70, np.sqrt(1.0 - 0.70**2)]), 1068 status="confirmed", 1069 confirmed_entity="alice_test", 1070 ) 1071 tracker = _seed_candidate_tracker(curation_journal, [left, right]) 1072 anchor_a, anchor_b = _record_pair_for_candidates( 1073 tracker.load_all_candidates()[0], 1074 tracker.load_all_candidates()[1], 1075 ) 1076 1077 result = accept_speaker_candidate_pair(anchor_b, anchor_a) 1078 1079 assert calls == [] 1080 assert result["status"] == "accepted" 1081 assert result["kind"] == KIND_SPEAKER_CANDIDATE_PAIR 1082 assert result["merge"]["status"] == "merged" 1083 assert result["undo"] == { 1084 "available": False, 1085 "merge_id": None, 1086 "reason": "Speaker candidate-pair merges cannot be undone.", 1087 } 1088 survivor = CandidateTracker( 1089 curation_journal / "awareness" / "speaker_candidates.json" 1090 ).load_all_candidates() 1091 assert len(survivor) == 1 1092 assert survivor[0].cand_id == 1 1093 assert survivor[0].status == "confirmed" 1094 assert survivor[0].confirmed_entity == "alice_test" 1095 assert load_pair_candidates()[0]["status"] == "accepted" 1096 1097 1098def test_accept_speaker_candidate_pair_stale_tracker_error_keeps_row_open( 1099 curation_journal, 1100 monkeypatch, 1101): 1102 calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] 1103 monkeypatch.setattr( 1104 curation, 1105 "merge_entity", 1106 lambda *args, **kwargs: calls.append((args, kwargs)), 1107 ) 1108 left = _candidate_profile(1, _unit([1.0, 0.0])) 1109 right = _candidate_profile(2, _unit([0.70, np.sqrt(1.0 - 0.70**2)])) 1110 anchor_a, anchor_b = _record_pair_for_candidates(left, right) 1111 1112 result = accept_speaker_candidate_pair(anchor_a, anchor_b) 1113 1114 assert calls == [] 1115 assert result == { 1116 "status": "error", 1117 "kind": KIND_SPEAKER_CANDIDATE_PAIR, 1118 "key": curation._speaker_candidate_pair_key(anchor_a, anchor_b), 1119 "error": "candidate anchor not found", 1120 } 1121 assert load_pair_candidates()[0]["status"] == "open" 1122 assert ( 1123 CandidateTracker( 1124 curation_journal / "awareness" / "speaker_candidates.json" 1125 ).load_all_candidates() 1126 == [] 1127 ) 1128 1129 1130def test_dismiss_speaker_candidate_pair_sets_status_and_removes_open_item( 1131 curation_journal, 1132): 1133 left = _candidate_profile(1, _unit([1.0, 0.0])) 1134 right = _candidate_profile(2, _unit([0.62, np.sqrt(1.0 - 0.62**2)])) 1135 anchor_a, anchor_b = _record_pair_for_candidates(left, right, similarity=0.62) 1136 1137 result = dismiss_speaker_candidate_pair(anchor_b, anchor_a) 1138 1139 assert result["status"] == "dismissed" 1140 assert result["kind"] == KIND_SPEAKER_CANDIDATE_PAIR 1141 assert load_pair_candidates()[0]["status"] == "dismissed" 1142 assert [ 1143 item for item in load_open_items() if item.kind == KIND_SPEAKER_CANDIDATE_PAIR 1144 ] == [] 1145 1146 1147def test_merge_preview_fields_returns_compact_summary(): 1148 fields = merge_preview_fields( 1149 { 1150 "would_identity": { 1151 "akas_added": ["Kognova Inc"], 1152 "emails_added_count": 1, 1153 }, 1154 "would_facets": { 1155 "moved_count": 2, 1156 "merged_count": 3, 1157 "observations_appended": 4, 1158 }, 1159 "would_segments": { 1160 "labels_rewritten": 5, 1161 "corrections_rewritten": 6, 1162 "errors": [{"message": "bad"}], 1163 }, 1164 "would_voiceprints": { 1165 "added": 7, 1166 "target_total": 8, 1167 }, 1168 } 1169 ) 1170 1171 assert fields == { 1172 "akas_added": ["Kognova Inc"], 1173 "emails_added_count": 1, 1174 "facet_moved_count": 2, 1175 "facet_merged_count": 3, 1176 "observations_appended": 4, 1177 "labels_rewritten": 5, 1178 "corrections_rewritten": 6, 1179 "segment_errors": [{"message": "bad"}], 1180 "voiceprints_added": 7, 1181 "voiceprints_target_total": 8, 1182 }