|
1 | 1 | """Unit tests for Phase D conversation system.""" |
2 | 2 |
|
| 3 | +import asyncio |
| 4 | +from datetime import datetime |
| 5 | +from unittest.mock import patch |
| 6 | + |
3 | 7 | from extropy.simulation.conversation import ( |
4 | 8 | ConversationRequest, |
5 | 9 | ConversationMessage, |
6 | 10 | ConversationResult, |
7 | 11 | ConversationStateChange, |
8 | 12 | collect_conversation_requests, |
| 13 | + execute_conversation_async, |
9 | 14 | prioritize_and_resolve_conflicts, |
10 | 15 | ) |
11 | 16 | from extropy.core.models import ( |
| 17 | + ExposureRecord, |
| 18 | + ReasoningContext, |
12 | 19 | ReasoningResponse, |
13 | 20 | SimulationRunConfig, |
14 | 21 | ) |
| 22 | +from extropy.core.models.scenario import ( |
| 23 | + Event, |
| 24 | + EventType, |
| 25 | + ExposureChannel, |
| 26 | + ExposureRule, |
| 27 | + InteractionConfig, |
| 28 | + InteractionType, |
| 29 | + OutcomeConfig, |
| 30 | + ScenarioMeta, |
| 31 | + ScenarioSpec, |
| 32 | + SeedExposure, |
| 33 | + SimulationConfig, |
| 34 | + SpreadConfig, |
| 35 | + TimestepUnit, |
| 36 | +) |
| 37 | + |
| 38 | + |
| 39 | +def _make_scenario() -> ScenarioSpec: |
| 40 | + return ScenarioSpec( |
| 41 | + meta=ScenarioMeta( |
| 42 | + name="conv_test", |
| 43 | + description="Conversation test scenario", |
| 44 | + population_spec="test.yaml", |
| 45 | + study_db="study.db", |
| 46 | + population_id="default", |
| 47 | + network_id="default", |
| 48 | + created_at=datetime(2024, 1, 1), |
| 49 | + ), |
| 50 | + event=Event( |
| 51 | + type=EventType.NEWS, |
| 52 | + content="The city council announced a major policy change.", |
| 53 | + source="City Council", |
| 54 | + credibility=0.8, |
| 55 | + ambiguity=0.2, |
| 56 | + emotional_valence=0.0, |
| 57 | + ), |
| 58 | + seed_exposure=SeedExposure( |
| 59 | + channels=[ |
| 60 | + ExposureChannel( |
| 61 | + name="broadcast", |
| 62 | + description="Broadcast", |
| 63 | + reach="broadcast", |
| 64 | + credibility_modifier=1.0, |
| 65 | + ) |
| 66 | + ], |
| 67 | + rules=[ |
| 68 | + ExposureRule( |
| 69 | + channel="broadcast", |
| 70 | + timestep=0, |
| 71 | + when="true", |
| 72 | + probability=1.0, |
| 73 | + ) |
| 74 | + ], |
| 75 | + ), |
| 76 | + interaction=InteractionConfig( |
| 77 | + primary_model=InteractionType.DIRECT_CONVERSATION, |
| 78 | + description="Direct conversations", |
| 79 | + ), |
| 80 | + spread=SpreadConfig(share_probability=0.4), |
| 81 | + outcomes=OutcomeConfig(suggested_outcomes=[]), |
| 82 | + simulation=SimulationConfig(max_timesteps=3, timestep_unit=TimestepUnit.DAY), |
| 83 | + ) |
| 84 | + |
| 85 | + |
| 86 | +def _make_context(agent_id: str, name: str) -> ReasoningContext: |
| 87 | + return ReasoningContext( |
| 88 | + agent_id=agent_id, |
| 89 | + persona=f"I am {name}.", |
| 90 | + event_content="Policy update", |
| 91 | + exposure_history=[ |
| 92 | + ExposureRecord( |
| 93 | + timestep=0, |
| 94 | + channel="broadcast", |
| 95 | + content="Policy update", |
| 96 | + credibility=0.8, |
| 97 | + source_agent_id=None, |
| 98 | + ) |
| 99 | + ], |
| 100 | + agent_name=name, |
| 101 | + ) |
15 | 102 |
|
16 | 103 |
|
17 | 104 | class TestCollectConversationRequests: |
@@ -409,3 +496,116 @@ def test_reasoning_response_empty_actions(self): |
409 | 496 | conviction=0.3, |
410 | 497 | ) |
411 | 498 | assert response.actions == [] |
| 499 | + |
| 500 | + |
| 501 | +class TestConversationExecution: |
| 502 | + def test_medium_fidelity_executes_4_messages(self): |
| 503 | + request = ConversationRequest( |
| 504 | + initiator_id="a1", |
| 505 | + target_id="a2", |
| 506 | + target_name="Blake", |
| 507 | + relationship="friend", |
| 508 | + topic="what to do next", |
| 509 | + ) |
| 510 | + initiator_ctx = _make_context("a1", "Alex") |
| 511 | + target_ctx = _make_context("a2", "Blake") |
| 512 | + scenario = _make_scenario() |
| 513 | + config = SimulationRunConfig( |
| 514 | + scenario_path="test.yaml", |
| 515 | + output_dir="test/", |
| 516 | + fidelity="medium", |
| 517 | + ) |
| 518 | + |
| 519 | + call_counter = {"count": 0} |
| 520 | + |
| 521 | + async def _mock_simple_call_async(*args, **kwargs): |
| 522 | + call_counter["count"] += 1 |
| 523 | + return ( |
| 524 | + { |
| 525 | + "response": f"message-{call_counter['count']}", |
| 526 | + "internal_reaction": "thinking", |
| 527 | + "updated_sentiment": 0.1, |
| 528 | + "updated_conviction": 60, |
| 529 | + }, |
| 530 | + None, |
| 531 | + ) |
| 532 | + |
| 533 | + with patch( |
| 534 | + "extropy.simulation.conversation.simple_call_async", |
| 535 | + new=_mock_simple_call_async, |
| 536 | + ): |
| 537 | + result = asyncio.run( |
| 538 | + execute_conversation_async( |
| 539 | + request=request, |
| 540 | + initiator_context=initiator_ctx, |
| 541 | + target_context=target_ctx, |
| 542 | + target_npc_profile=None, |
| 543 | + scenario=scenario, |
| 544 | + config=config, |
| 545 | + ) |
| 546 | + ) |
| 547 | + |
| 548 | + assert call_counter["count"] == 4 |
| 549 | + assert len(result.messages) == 4 |
| 550 | + assert [m.speaker_id for m in result.messages] == ["a1", "a2", "a1", "a2"] |
| 551 | + assert sum(1 for m in result.messages if m.is_final) == 1 |
| 552 | + assert result.messages[-1].is_final is True |
| 553 | + |
| 554 | + def test_high_fidelity_executes_6_messages(self): |
| 555 | + request = ConversationRequest( |
| 556 | + initiator_id="a1", |
| 557 | + target_id="a2", |
| 558 | + target_name="Blake", |
| 559 | + relationship="friend", |
| 560 | + topic="what to do next", |
| 561 | + ) |
| 562 | + initiator_ctx = _make_context("a1", "Alex") |
| 563 | + target_ctx = _make_context("a2", "Blake") |
| 564 | + scenario = _make_scenario() |
| 565 | + config = SimulationRunConfig( |
| 566 | + scenario_path="test.yaml", |
| 567 | + output_dir="test/", |
| 568 | + fidelity="high", |
| 569 | + ) |
| 570 | + |
| 571 | + call_counter = {"count": 0} |
| 572 | + |
| 573 | + async def _mock_simple_call_async(*args, **kwargs): |
| 574 | + call_counter["count"] += 1 |
| 575 | + return ( |
| 576 | + { |
| 577 | + "response": f"message-{call_counter['count']}", |
| 578 | + "internal_reaction": "thinking", |
| 579 | + "updated_sentiment": 0.1, |
| 580 | + "updated_conviction": 60, |
| 581 | + }, |
| 582 | + None, |
| 583 | + ) |
| 584 | + |
| 585 | + with patch( |
| 586 | + "extropy.simulation.conversation.simple_call_async", |
| 587 | + new=_mock_simple_call_async, |
| 588 | + ): |
| 589 | + result = asyncio.run( |
| 590 | + execute_conversation_async( |
| 591 | + request=request, |
| 592 | + initiator_context=initiator_ctx, |
| 593 | + target_context=target_ctx, |
| 594 | + target_npc_profile=None, |
| 595 | + scenario=scenario, |
| 596 | + config=config, |
| 597 | + ) |
| 598 | + ) |
| 599 | + |
| 600 | + assert call_counter["count"] == 6 |
| 601 | + assert len(result.messages) == 6 |
| 602 | + assert [m.speaker_id for m in result.messages] == [ |
| 603 | + "a1", |
| 604 | + "a2", |
| 605 | + "a1", |
| 606 | + "a2", |
| 607 | + "a1", |
| 608 | + "a2", |
| 609 | + ] |
| 610 | + assert sum(1 for m in result.messages if m.is_final) == 1 |
| 611 | + assert result.messages[-1].is_final is True |
0 commit comments