Skip to content

Commit de8ff9e

Browse files
Merge pull request #91 from exaforge/codex/issue-84-conversation-turns
Fix #84: enforce 4/6-message conversation depth by fidelity
2 parents a0eedca + 3a3a340 commit de8ff9e

2 files changed

Lines changed: 215 additions & 6 deletions

File tree

extropy/simulation/conversation.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,15 @@ def _build_npc_schema() -> dict[str, Any]:
463463
}
464464

465465

466+
def _message_count_for_fidelity(fidelity: str) -> int:
467+
"""Get total message count for a conversation at the given fidelity."""
468+
if fidelity == "high":
469+
return 6
470+
if fidelity == "medium":
471+
return 4
472+
return 2
473+
474+
466475
async def execute_conversation_async(
467476
request: ConversationRequest,
468477
initiator_context: ReasoningContext,
@@ -488,8 +497,8 @@ async def execute_conversation_async(
488497
Returns:
489498
ConversationResult with messages and state changes
490499
"""
491-
# Determine number of turns based on fidelity
492-
turns = 2 if config.fidelity == "medium" else 3 # 4 or 6 messages
500+
# Determine number of messages based on fidelity
501+
total_messages = _message_count_for_fidelity(config.fidelity)
493502

494503
messages: list[ConversationMessage] = []
495504
initiator_name = initiator_context.agent_name or "Agent"
@@ -507,10 +516,10 @@ async def execute_conversation_async(
507516
npc_schema = _build_npc_schema()
508517
model = config.fast or None # Use fast model for conversations
509518

510-
for turn in range(turns):
519+
for turn in range(total_messages):
511520
# Alternate speakers
512521
is_initiator_turn = turn % 2 == 0
513-
is_final = turn == turns - 1 or (not is_initiator_turn and turn == turns - 2)
522+
is_final = turn == total_messages - 1
514523

515524
if is_initiator_turn:
516525
# Initiator speaks
@@ -522,7 +531,7 @@ async def execute_conversation_async(
522531
topic=request.topic,
523532
prior_messages=messages,
524533
scenario=scenario,
525-
is_final=is_final and turn == turns - 1,
534+
is_final=is_final,
526535
is_initiator=True,
527536
)
528537

@@ -552,7 +561,7 @@ async def execute_conversation_async(
552561
speaker_name=initiator_name,
553562
content=content,
554563
turn=turn,
555-
is_final=is_final and turn == turns - 1,
564+
is_final=is_final,
556565
)
557566
)
558567
initiator_sentiment = response.get("updated_sentiment")

tests/test_conversations.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,104 @@
11
"""Unit tests for Phase D conversation system."""
22

3+
import asyncio
4+
from datetime import datetime
5+
from unittest.mock import patch
6+
37
from extropy.simulation.conversation import (
48
ConversationRequest,
59
ConversationMessage,
610
ConversationResult,
711
ConversationStateChange,
812
collect_conversation_requests,
13+
execute_conversation_async,
914
prioritize_and_resolve_conflicts,
1015
)
1116
from extropy.core.models import (
17+
ExposureRecord,
18+
ReasoningContext,
1219
ReasoningResponse,
1320
SimulationRunConfig,
1421
)
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+
)
15102

16103

17104
class TestCollectConversationRequests:
@@ -409,3 +496,116 @@ def test_reasoning_response_empty_actions(self):
409496
conviction=0.3,
410497
)
411498
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

Comments
 (0)