personal memory agent
0

Configure Feed

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

solstone / tests / test_story_hook.py
24 kB 725 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4import asyncio 5import json 6import logging 7from pathlib import Path 8 9 10def _write_detected_entities(tmp_path, facet: str, day: str, rows: list[dict]) -> None: 11 path = tmp_path / "facets" / facet / "entities" / f"{day}.jsonl" 12 path.parent.mkdir(parents=True, exist_ok=True) 13 path.write_text( 14 "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows), 15 encoding="utf-8", 16 ) 17 18 19def _activity_record(record_id: str = "meeting_090000_300") -> dict: 20 return { 21 "id": record_id, 22 "activity": "meeting", 23 "description": "Team sync", 24 "segments": ["090000_300"], 25 "created_at": 1, 26 } 27 28 29def _context( 30 tmp_path: Path, 31 *, 32 facet: str = "work", 33 day: str = "20260418", 34 record_id: str = "meeting_090000_300", 35 name: str = "conversation", 36) -> dict: 37 return { 38 "facet": facet, 39 "day": day, 40 "name": name, 41 "activity": {"id": record_id}, 42 "output_path": str( 43 tmp_path / "facets" / facet / "activities" / day / record_id / "story.json" 44 ), 45 } 46 47 48def _valid_result(**overrides) -> str: 49 payload = { 50 "body": "Aligned on launch work and assigned the follow-up.", 51 "topics": ["launch", "follow-up"], 52 "confidence": 0.82, 53 "commitments": [ 54 { 55 "owner": "Mina", 56 "action": "send the revised deck", 57 "counterparty": "Ravi", 58 "when": "Friday morning", 59 "context": "Mina committed to send the deck before the next investor call.", 60 } 61 ], 62 "closures": [ 63 { 64 "owner": "Ravi", 65 "action": "intro email", 66 "counterparty": "Mina", 67 "resolution": "sent", 68 "context": "Ravi confirmed the intro email already went out.", 69 } 70 ], 71 "decisions": [ 72 { 73 "owner": "Team", 74 "action": "move the launch review to Tuesday", 75 "counterparty": None, 76 "context": "The group aligned on Tuesday after checking calendars.", 77 } 78 ], 79 "relations": [], 80 } 81 payload.update(overrides) 82 return json.dumps(payload) 83 84 85def _load_record(facet: str, day: str): 86 from solstone.think.activities import load_activity_records 87 88 return load_activity_records(facet, day, include_hidden=True)[0] 89 90 91def test_story_hook_parses_and_writes(tmp_path, monkeypatch): 92 from solstone.talent.story import post_process 93 from solstone.think.activities import append_activity_record 94 95 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 96 97 append_activity_record("work", "20260418", _activity_record()) 98 99 returned = post_process( 100 _valid_result(body=" Wrapped the launch prep and assigned follow-up. "), 101 _context(tmp_path), 102 ) 103 104 record = _load_record("work", "20260418") 105 assert returned == "" 106 assert record["story"] == { 107 "talent": "conversation", 108 "body": "Wrapped the launch prep and assigned follow-up.", 109 "topics": ["launch", "follow-up"], 110 "confidence": 0.82, 111 } 112 assert record["commitments"][0]["owner"] == "Mina" 113 assert record["closures"][0]["resolution"] == "sent" 114 assert record["decisions"][0]["owner"] == "Team" 115 assert record["relations"] == [] 116 assert record["edits"][-1]["actor"] == "story" 117 assert record["edits"][-1]["fields"] == [ 118 "story", 119 "commitments", 120 "closures", 121 "decisions", 122 "relations", 123 ] 124 125 126def test_story_hook_empty_arrays(tmp_path, monkeypatch): 127 from solstone.talent.story import post_process 128 from solstone.think.activities import append_activity_record 129 130 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 131 append_activity_record("work", "20260418", _activity_record()) 132 133 post_process( 134 _valid_result(commitments=[], closures=[], decisions=[]), 135 _context(tmp_path), 136 ) 137 138 record = _load_record("work", "20260418") 139 assert ( 140 record["story"]["body"] == "Aligned on launch work and assigned the follow-up." 141 ) 142 assert record["commitments"] == [] 143 assert record["closures"] == [] 144 assert record["decisions"] == [] 145 assert record["relations"] == [] 146 147 148def test_story_hook_bad_resolution_skipped(tmp_path, monkeypatch, caplog): 149 from solstone.talent.story import post_process 150 from solstone.think.activities import append_activity_record 151 152 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 153 append_activity_record("work", "20260418", _activity_record()) 154 155 post_process( 156 _valid_result( 157 closures=[ 158 { 159 "owner": "Ravi", 160 "action": "intro email", 161 "counterparty": "Mina", 162 "resolution": "sent", 163 "context": "The intro email went out.", 164 }, 165 { 166 "owner": "Ravi", 167 "action": "budget request", 168 "counterparty": "Finance", 169 "resolution": "approved", 170 "context": "This resolution is invalid for the schema.", 171 }, 172 ] 173 ), 174 _context(tmp_path), 175 ) 176 177 record = _load_record("work", "20260418") 178 assert [closure["action"] for closure in record["closures"]] == ["intro email"] 179 assert "invalid resolution 'approved'" in caplog.text 180 181 182def test_story_hook_missing_required_field_skipped(tmp_path, monkeypatch, caplog): 183 from solstone.talent.story import post_process 184 from solstone.think.activities import append_activity_record 185 186 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 187 append_activity_record("work", "20260418", _activity_record()) 188 189 post_process( 190 _valid_result( 191 commitments=[ 192 { 193 "owner": "Mina", 194 "action": "send the revised deck", 195 "counterparty": "Ravi", 196 "when": "Friday morning", 197 "context": "Valid commitment.", 198 }, 199 { 200 "owner": "Mina", 201 "action": "book the room", 202 "when": "tomorrow", 203 "context": "Missing counterparty should skip.", 204 }, 205 ], 206 closures=[ 207 { 208 "owner": "Ravi", 209 "action": "intro email", 210 "counterparty": "Mina", 211 "resolution": "sent", 212 "context": "Valid closure.", 213 }, 214 { 215 "action": "parking pass", 216 "counterparty": "Travel desk", 217 "resolution": "done", 218 "context": "Missing owner should skip.", 219 }, 220 ], 221 decisions=[ 222 { 223 "owner": "Team", 224 "action": "move the launch review to Tuesday", 225 "counterparty": None, 226 "context": "Valid decision.", 227 }, 228 { 229 "owner": "Team", 230 "context": "Missing action should skip.", 231 }, 232 ], 233 ), 234 _context(tmp_path), 235 ) 236 237 record = _load_record("work", "20260418") 238 assert len(record["commitments"]) == 1 239 assert len(record["closures"]) == 1 240 assert len(record["decisions"]) == 1 241 assert "missing required string field" in caplog.text 242 243 244def test_story_hook_resolves_entities(tmp_path, monkeypatch): 245 from solstone.talent.story import post_process 246 from solstone.think.activities import append_activity_record 247 248 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 249 _write_detected_entities( 250 tmp_path, 251 "work", 252 "20260418", 253 [ 254 {"id": "mina_lee", "type": "Person", "name": "Mina Lee", "aka": ["Mina"]}, 255 {"id": "ravi_shah", "type": "Person", "name": "Ravi Shah", "aka": ["Ravi"]}, 256 ], 257 ) 258 append_activity_record("work", "20260418", _activity_record()) 259 260 post_process( 261 _valid_result( 262 commitments=[ 263 { 264 "owner": "Mina", 265 "action": "send the revised deck", 266 "counterparty": "Ravi", 267 "when": "Friday morning", 268 "context": "Valid commitment.", 269 }, 270 { 271 "owner": "Unknown Owner", 272 "action": "draft the note", 273 "counterparty": "Unknown Counterparty", 274 "when": "later", 275 "context": "Unmatched names should stay null.", 276 }, 277 ], 278 closures=[ 279 { 280 "owner": "Ravi", 281 "action": "intro email", 282 "counterparty": "Mina", 283 "resolution": "sent", 284 "context": "Valid closure.", 285 } 286 ], 287 decisions=[ 288 { 289 "owner": "Mina Lee", 290 "action": "move the launch review to Tuesday", 291 "counterparty": "Ravi", 292 "context": "Valid decision.", 293 }, 294 { 295 "owner": "Team", 296 "action": "keep the review owner unchanged", 297 "counterparty": None, 298 "context": "Decision without a counterparty.", 299 }, 300 ], 301 relations=[ 302 { 303 "from": "Mina", 304 "to": "Ravi", 305 "kind": "works-with", 306 "note": "", 307 "quote": "Mina and Ravi will handle the deck.", 308 }, 309 { 310 "from": "Mina", 311 "to": "Nobody Visible", 312 "kind": "knows", 313 "note": "Mina mentioned knowing someone outside the tracked entities.", 314 "quote": None, 315 }, 316 ], 317 ), 318 _context(tmp_path), 319 ) 320 321 record = _load_record("work", "20260418") 322 assert record["commitments"][0]["owner_entity_id"] == "mina_lee" 323 assert record["commitments"][0]["counterparty_entity_id"] == "ravi_shah" 324 assert record["commitments"][1]["owner_entity_id"] is None 325 assert record["commitments"][1]["counterparty_entity_id"] is None 326 assert record["closures"][0]["owner_entity_id"] == "ravi_shah" 327 assert record["closures"][0]["counterparty_entity_id"] == "mina_lee" 328 assert record["decisions"][0]["owner_entity_id"] == "mina_lee" 329 assert record["decisions"][0]["counterparty"] == "Ravi" 330 assert record["decisions"][0]["counterparty_entity_id"] == "ravi_shah" 331 assert record["decisions"][1]["counterparty"] is None 332 assert record["decisions"][1]["counterparty_entity_id"] is None 333 assert record["relations"][0]["from_entity_id"] == "mina_lee" 334 assert record["relations"][0]["to_entity_id"] == "ravi_shah" 335 assert record["relations"][1]["to"] == "Nobody Visible" 336 assert record["relations"][1]["to_entity_id"] is None 337 assert record["relations"][1]["note"] == ( 338 "Mina mentioned knowing someone outside the tracked entities." 339 ) 340 assert record["relations"][1]["quote"] is None 341 assert record["commitments"][0]["owner"] == "Mina" 342 assert record["closures"][0]["counterparty"] == "Mina" 343 344 345def test_story_hook_ambiguous_entities_keep_names_and_null_ids(tmp_path, monkeypatch): 346 from solstone.talent.story import post_process 347 from solstone.think.activities import append_activity_record 348 from solstone.think.entities import load_ambiguities 349 350 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 351 _write_detected_entities( 352 tmp_path, 353 "work", 354 "20260418", 355 [ 356 {"id": "sarah_connor", "type": "Person", "name": "Sarah Connor"}, 357 {"id": "sarah_lee", "type": "Person", "name": "Sarah Lee"}, 358 ], 359 ) 360 append_activity_record("work", "20260418", _activity_record()) 361 362 post_process( 363 _valid_result( 364 commitments=[ 365 { 366 "owner": "Sarah", 367 "action": "send the revised deck", 368 "counterparty": "Sarah", 369 "when": "Friday morning", 370 "context": "Ambiguous Sarah should not attach.", 371 } 372 ], 373 closures=[], 374 decisions=[], 375 relations=[ 376 { 377 "from": "Sarah", 378 "to": "Sarah", 379 "kind": "works-with", 380 "note": "Ambiguous relationship.", 381 } 382 ], 383 ), 384 _context(tmp_path), 385 ) 386 387 record = _load_record("work", "20260418") 388 assert record["commitments"][0]["owner"] == "Sarah" 389 assert record["commitments"][0]["owner_entity_id"] is None 390 assert record["commitments"][0]["counterparty_entity_id"] is None 391 assert record["relations"][0]["from"] == "Sarah" 392 assert record["relations"][0]["from_entity_id"] is None 393 assert record["relations"][0]["to_entity_id"] is None 394 rows = load_ambiguities() 395 assert len(rows) == 1 396 assert rows[0]["normalized_query"] == "sarah" 397 398 399def test_story_hook_skips_invalid_relations(tmp_path, monkeypatch, caplog): 400 from solstone.talent.story import post_process 401 from solstone.think.activities import append_activity_record 402 403 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 404 append_activity_record("work", "20260418", _activity_record()) 405 406 post_process( 407 _valid_result( 408 relations=[ 409 { 410 "from": "Mina", 411 "to": "Ravi", 412 "kind": "works-with", 413 "note": "", 414 "quote": "They are paired on the deck.", 415 }, 416 { 417 "from": "Mina", 418 "to": "Ravi", 419 "kind": "other", 420 "note": " ", 421 "quote": "They have a custom relationship.", 422 }, 423 { 424 "from": "Mina", 425 "to": "Ravi", 426 "kind": "mentors", 427 "note": "Mina mentors Ravi.", 428 "quote": "Mina mentors Ravi.", 429 }, 430 ] 431 ), 432 _context(tmp_path), 433 ) 434 435 record = _load_record("work", "20260418") 436 assert [relation["kind"] for relation in record["relations"]] == ["works-with"] 437 assert "other kind requires note" in caplog.text 438 assert "invalid kind 'mentors'" in caplog.text 439 440 441def test_story_hook_idempotent_rerun(tmp_path, monkeypatch): 442 from solstone.talent.story import post_process 443 from solstone.think.activities import append_activity_record 444 445 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 446 append_activity_record("work", "20260418", _activity_record()) 447 448 post_process( 449 _valid_result( 450 relations=[ 451 { 452 "from": "Mina", 453 "to": "Ravi", 454 "kind": "works-with", 455 "note": "", 456 "quote": "Mina and Ravi owned the first pass.", 457 } 458 ] 459 ), 460 _context(tmp_path), 461 ) 462 first = _load_record("work", "20260418") 463 assert len(first["edits"]) == 1 464 assert first["relations"][0]["kind"] == "works-with" 465 466 post_process( 467 _valid_result( 468 body="Second pass with a clearer summary.", 469 topics=["handoff"], 470 commitments=[], 471 closures=[], 472 decisions=[ 473 { 474 "owner": "Lead", 475 "action": "ship the patch on Wednesday", 476 "counterparty": None, 477 "context": "The second pass reached a more specific plan.", 478 } 479 ], 480 relations=[ 481 { 482 "from": "Lead", 483 "to": "Patch", 484 "kind": "created", 485 "note": "", 486 "quote": None, 487 } 488 ], 489 ), 490 _context(tmp_path), 491 ) 492 493 second = _load_record("work", "20260418") 494 assert second["story"] == { 495 "talent": "conversation", 496 "body": "Second pass with a clearer summary.", 497 "topics": ["handoff"], 498 "confidence": 0.82, 499 } 500 assert second["commitments"] == [] 501 assert second["closures"] == [] 502 assert second["decisions"] == [ 503 { 504 "owner": "Lead", 505 "action": "ship the patch on Wednesday", 506 "counterparty": None, 507 "context": "The second pass reached a more specific plan.", 508 "owner_entity_id": None, 509 "counterparty_entity_id": None, 510 } 511 ] 512 assert second["relations"] == [ 513 { 514 "from": "Lead", 515 "to": "Patch", 516 "kind": "created", 517 "note": "", 518 "quote": None, 519 "from_entity_id": None, 520 "to_entity_id": None, 521 } 522 ] 523 assert len(second["edits"]) == 2 524 assert second["edits"][-1]["fields"] == [ 525 "story", 526 "commitments", 527 "closures", 528 "decisions", 529 "relations", 530 ] 531 532 533def test_story_hook_normalizes_topics(tmp_path, monkeypatch): 534 from solstone.talent.story import post_process 535 from solstone.think.activities import append_activity_record 536 537 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 538 append_activity_record("work", "20260418", _activity_record()) 539 540 post_process( 541 _valid_result( 542 topics=["Launch Plan", "follow-up", "LAUNCH plan", " ", "follow-up"] 543 ), 544 _context(tmp_path), 545 ) 546 547 record = _load_record("work", "20260418") 548 assert record["story"]["topics"] == ["launch plan", "follow-up"] 549 550 551def test_story_hook_clamps_confidence(tmp_path, monkeypatch, caplog): 552 from solstone.talent.story import post_process 553 from solstone.think.activities import append_activity_record, load_activity_records 554 555 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 556 caplog.set_level(logging.WARNING) 557 558 append_activity_record( 559 "work", 560 "20260418", 561 _activity_record(record_id="meeting_090000_300"), 562 ) 563 post_process(_valid_result(confidence=1.4), _context(tmp_path)) 564 first = _load_record("work", "20260418") 565 assert first["story"]["confidence"] == 1.0 566 assert "clamped" in caplog.text 567 568 append_activity_record( 569 "work", 570 "20260418", 571 _activity_record(record_id="meeting_100000_300"), 572 ) 573 post_process( 574 _valid_result(confidence=-0.2), 575 _context(tmp_path, record_id="meeting_100000_300"), 576 ) 577 records = load_activity_records("work", "20260418", include_hidden=True) 578 by_id = {record["id"]: record for record in records} 579 assert by_id["meeting_090000_300"]["story"]["confidence"] == 1.0 580 assert by_id["meeting_100000_300"]["story"]["confidence"] == 0.0 581 582 583def test_story_hook_rejects_nan_confidence(tmp_path, monkeypatch): 584 from solstone.talent.story import post_process 585 from solstone.think.activities import append_activity_record 586 587 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 588 append_activity_record("work", "20260418", _activity_record()) 589 590 returned = post_process( 591 _valid_result(confidence=float("nan")), 592 _context(tmp_path), 593 ) 594 595 record = _load_record("work", "20260418") 596 assert returned == "" 597 assert "story" not in record 598 599 600def test_story_hook_rejects_non_numeric_confidence(tmp_path, monkeypatch): 601 from solstone.talent.story import post_process 602 from solstone.think.activities import append_activity_record 603 604 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 605 append_activity_record("work", "20260418", _activity_record()) 606 607 returned = post_process( 608 _valid_result(confidence="high"), 609 _context(tmp_path), 610 ) 611 612 record = _load_record("work", "20260418") 613 assert returned == "" 614 assert "story" not in record 615 616 617def test_story_hook_missing_record_logs_and_returns(tmp_path, monkeypatch, caplog): 618 from solstone.talent.story import post_process 619 620 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 621 622 returned = post_process(_valid_result(), _context(tmp_path)) 623 624 assert returned == "" 625 assert "activity record not found" in caplog.text 626 627 628def test_story_hook_no_json_file_written(tmp_path, monkeypatch): 629 from solstone.talent.story import post_process 630 from solstone.think.activities import append_activity_record 631 632 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 633 append_activity_record("work", "20260418", _activity_record()) 634 635 output_path = ( 636 tmp_path 637 / "facets" 638 / "work" 639 / "activities" 640 / "20260418" 641 / "meeting_090000_300" 642 / "story.json" 643 ) 644 returned = post_process(_valid_result(), _context(tmp_path)) 645 646 assert returned == "" 647 assert not output_path.exists() 648 649 650def test_generate_seam_story_hook_writes_no_output_file(tmp_path, monkeypatch): 651 from solstone.think.activities import append_activity_record 652 from solstone.think.talents import _execute_generate 653 654 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 655 append_activity_record("work", "20260418", _activity_record()) 656 657 output_path = ( 658 tmp_path 659 / "facets" 660 / "work" 661 / "activities" 662 / "20260418" 663 / "meeting_090000_300" 664 / "conversation.json" 665 ) 666 config = { 667 **_context(tmp_path), 668 "output": "json", 669 "output_path": str(output_path), 670 "hook": {"post": "story"}, 671 "prompt": "x", 672 } 673 monkeypatch.setattr( 674 "solstone.think.models.generate_with_result", 675 lambda **kwargs: {"text": _valid_result()}, 676 ) 677 678 # Exercises the talents.py write-seam gate removed from cortex in 56b5a121. 679 asyncio.run(_execute_generate(config, lambda e: None)) 680 681 assert not output_path.exists() 682 record = _load_record("work", "20260418") 683 assert ( 684 record["story"]["body"] == "Aligned on launch work and assigned the follow-up." 685 ) 686 assert record["edits"][-1]["actor"] == "story" 687 688 689def test_generate_seam_participation_writes_json(tmp_path, monkeypatch): 690 from solstone.think.activities import append_activity_record 691 from solstone.think.talents import _execute_generate 692 693 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 694 append_activity_record("work", "20260418", _activity_record()) 695 696 output_path = ( 697 tmp_path 698 / "facets" 699 / "work" 700 / "activities" 701 / "20260418" 702 / "meeting_090000_300" 703 / "participation.json" 704 ) 705 payload = { 706 "participation": [{"name": "Mina", "role": "mentioned", "source": "text"}] 707 } 708 config = { 709 **_context(tmp_path, name="participation"), 710 "output": "json", 711 "output_path": str(output_path), 712 "hook": {"post": "participation"}, 713 "prompt": "x", 714 } 715 monkeypatch.setattr( 716 "solstone.think.models.generate_with_result", 717 lambda **kwargs: {"text": json.dumps(payload)}, 718 ) 719 720 # Exercises the talents.py write-seam gate removed from cortex in 56b5a121. 721 asyncio.run(_execute_generate(config, lambda e: None)) 722 723 assert output_path.exists() 724 assert output_path.stat().st_size > 0 725 assert json.loads(output_path.read_text()) == payload