Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 52 additions & 17 deletions backend/app/services/simulation_config_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ def generate_config(
enable_twitter: bool = True,
enable_reddit: bool = True,
progress_callback: Optional[Callable[[int, int, str], None]] = None,
poster_agent_pool: Optional[List[AgentActivityConfig]] = None,
) -> SimulationParameters:
"""
Intelligently generates a complete simulation configuration (step-by-step generation)
Expand All @@ -269,6 +270,13 @@ def generate_config(
enable_twitter: Whether to enable Twitter
enable_reddit: Whether to enable Reddit
progress_callback: Progress callback function (current_step, total_steps, message)
poster_agent_pool: When the caller bypasses Zep entities for a preset
agent pool (e.g. the sentiment campaign's archetype library),
pass that pool here. It's used instead of `entities` for the
event-config LLM's available poster types and for matching
initial posts to a poster agent — otherwise `entities=[]`
leaves nothing to match against and every initial post falls
back to "highest influence agent" regardless of poster_type.

Returns:
SimulationParameters: Complete simulation parameters
Expand Down Expand Up @@ -305,7 +313,9 @@ def report_progress(step: int, message: str):

# ========== Step 2: Generate event configuration ==========
report_progress(2, "Generating event configuration and trending topics...")
event_config_result = self._generate_event_config(context, simulation_requirement, entities)
event_config_result = self._generate_event_config(
context, simulation_requirement, entities, poster_agent_pool=poster_agent_pool
)
event_config = self._parse_event_config(event_config_result)
reasoning_parts.append(f"Event config: {event_config_result.get('reasoning', 'Success')}")

Expand Down Expand Up @@ -333,7 +343,9 @@ def report_progress(step: int, message: str):

# ========== Assign poster Agents to initial posts ==========
logger.info("Assigning appropriate poster Agents to initial posts...")
event_config = self._assign_initial_post_agents(event_config, all_agent_configs)
event_config = self._assign_initial_post_agents(
event_config, poster_agent_pool if poster_agent_pool is not None else all_agent_configs
)
assigned_count = len([p for p in event_config.initial_posts if p.get("poster_agent_id") is not None])
reasoning_parts.append(f"Initial post assignment: {assigned_count} posts have been assigned a poster")

Expand Down Expand Up @@ -618,23 +630,35 @@ def _generate_event_config(
self,
context: str,
simulation_requirement: str,
entities: List[EntityNode]
entities: List[EntityNode],
poster_agent_pool: Optional[List[AgentActivityConfig]] = None,
) -> Dict[str, Any]:
"""Generate event configuration"""

# Get the list of available entity types for the LLM to reference
entity_types_available = list(set(
e.get_entity_type() or "Unknown" for e in entities
))

# List representative entity names for each type
type_examples = {}
for e in entities:
etype = e.get_entity_type() or "Unknown"
if etype not in type_examples:
type_examples[etype] = []
if len(type_examples[etype]) < 3:
type_examples[etype].append(e.name)
if poster_agent_pool is not None:
# Bypass entities entirely (e.g. archetype-based campaigns pass
# entities=[]) so the LLM only ever sees poster types that a real
# agent actually exists for.
entity_types_available = sorted({a.entity_type for a in poster_agent_pool})
type_examples: Dict[str, List[str]] = {}
for a in poster_agent_pool:
names = type_examples.setdefault(a.entity_type, [])
if len(names) < 3 and a.entity_name:
names.append(a.entity_name)
else:
# Get the list of available entity types for the LLM to reference
entity_types_available = list(set(
e.get_entity_type() or "Unknown" for e in entities
))

# List representative entity names for each type
type_examples = {}
for e in entities:
etype = e.get_entity_type() or "Unknown"
if etype not in type_examples:
type_examples[etype] = []
if len(type_examples[etype]) < 3:
type_examples[etype].append(e.name)

type_info = "\n".join([
f"- {t}: {', '.join(examples)}"
Expand All @@ -644,6 +668,17 @@ def _generate_event_config(
# Use the configured context truncation length
context_truncated = context[:self.EVENT_CONFIG_CONTEXT_LENGTH]

if poster_agent_pool is not None:
poster_type_hint = (
"For example: match each post's tone and stance to the archetype it's attributed to "
"(e.g. a skeptical archetype voicing doubts, an early-adopter archetype hyping the product)."
)
else:
poster_type_hint = (
"For example: official announcements should be published by Official/University types, "
"news by MediaOutlet, student opinions by Student."
)

prompt = f"""Based on the following simulation requirements, generate an event configuration.

Simulation requirement: {simulation_requirement}
Expand All @@ -660,7 +695,7 @@ def _generate_event_config(
- Design initial post content; **each post must specify a poster_type (poster's entity type)**

**Important**: poster_type must be chosen from the "Available Entity Types" listed above, so that initial posts can be assigned to the appropriate Agent for publishing.
For example: official announcements should be published by Official/University types, news by MediaOutlet, student opinions by Student.
{poster_type_hint}

Return JSON format (no markdown):
{{
Expand Down
73 changes: 50 additions & 23 deletions backend/app/services/simulation_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from ..utils.logger import get_logger
from .zep_entity_reader import ZepEntityReader, FilteredEntities
from .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile
from .simulation_config_generator import SimulationConfigGenerator, SimulationParameters
from .simulation_config_generator import SimulationConfigGenerator, SimulationParameters, AgentActivityConfig

logger = get_logger('mirofish.simulation')

Expand Down Expand Up @@ -226,6 +226,39 @@ def create_simulation(

return state

@staticmethod
def _build_archetype_agent_configs(archetype_map: List[Dict[str, Any]]) -> List[AgentActivityConfig]:
"""Build the agent-config pool for an archetype-based campaign.

Used both as `poster_agent_pool` (so the config generator's event-config
LLM and initial-post assignment match against real archetype types
instead of an empty entity list) and as the final `agent_configs`
written to simulation_config.json, so the two stay consistent.
"""
from .archetype_library import ARCHETYPE_DEFINITIONS

configs = []
for entry in archetype_map:
arch_key = entry.get('archetype', 'casual_browser')
defn = ARCHETYPE_DEFINITIONS.get(arch_key, {})
al = defn.get('activity_level', 0.5)
configs.append(AgentActivityConfig(
agent_id=entry['agent_id'],
entity_uuid=f"archetype_{arch_key}_{entry['agent_id']}",
entity_name=entry.get('username', ''),
entity_type=arch_key,
activity_level=al,
posts_per_hour=round(al * 0.8, 2),
comments_per_hour=round(al * 1.5, 2),
active_hours=list(range(8, 24)),
response_delay_min=5,
response_delay_max=60,
sentiment_bias=defn.get('sentiment_bias', 0.0),
stance="neutral",
influence_weight=defn.get('influence_weight', 1.0),
))
return configs

def prepare_simulation(
self,
simulation_id: str,
Expand Down Expand Up @@ -451,6 +484,15 @@ def profile_progress(current, total, msg):
total=3
)

# When using archetype profiles, build the real agent pool up front
# and hand it to the config generator as `poster_agent_pool`. With
# entities=[] the generator otherwise has nothing to match a
# generated initial-post poster_type against, so every post fell
# back to "highest influence agent" regardless of poster_type.
poster_agent_pool = None
if archetype_profiles is not None and archetype_map is not None:
poster_agent_pool = self._build_archetype_agent_configs(archetype_map)

sim_params = config_generator.generate_config(
simulation_id=simulation_id,
project_id=state.project_id,
Expand All @@ -459,7 +501,8 @@ def profile_progress(current, total, msg):
document_text=document_text,
entities=entities_for_config,
enable_twitter=state.enable_twitter,
enable_reddit=state.enable_reddit
enable_reddit=state.enable_reddit,
poster_agent_pool=poster_agent_pool,
)

if progress_callback:
Expand All @@ -477,33 +520,17 @@ def profile_progress(current, total, msg):

# When using archetype profiles, patch agents_per_hour and agent_configs
# because the config generator received 0 entities and defaults to minimums.
# Reuses the same poster_agent_pool passed into generate_config() above,
# so the initial-post poster assignments made against that pool stay
# consistent with the agent_configs actually written to disk.
if archetype_profiles is not None and archetype_map is not None:
from .archetype_library import ARCHETYPE_DEFINITIONS
from dataclasses import asdict
n = len(archetype_profiles)
with open(config_path, 'r', encoding='utf-8') as f:
config_data = json.load(f)
config_data['time_config']['agents_per_hour_min'] = max(5, n // 10)
config_data['time_config']['agents_per_hour_max'] = max(20, n // 3)
agent_configs = []
for entry in archetype_map:
arch_key = entry.get('archetype', 'casual_browser')
defn = ARCHETYPE_DEFINITIONS.get(arch_key, {})
al = defn.get('activity_level', 0.5)
agent_configs.append({
"agent_id": entry['agent_id'],
"entity_uuid": f"archetype_{arch_key}_{entry['agent_id']}",
"entity_name": entry.get('username', ''),
"entity_type": arch_key,
"activity_level": al,
"posts_per_hour": round(al * 0.8, 2),
"comments_per_hour": round(al * 1.5, 2),
"active_hours": list(range(8, 24)),
"response_delay_min": 5,
"response_delay_max": 60,
"sentiment_bias": defn.get('sentiment_bias', 0.0),
"stance": "neutral",
"influence_weight": defn.get('influence_weight', 1.0),
})
agent_configs = [asdict(a) for a in poster_agent_pool]
config_data['agent_configs'] = agent_configs
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config_data, f, ensure_ascii=False, indent=2)
Expand Down
117 changes: 117 additions & 0 deletions backend/tests/test_simulation_config_poster_pool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Tests for issue #29 (A4): domain-specific entity types generated by the
ontology were discarded for archetype-based (sentiment campaign) simulations.

`SimulationConfigGenerator.generate_config()` is called with `entities=[]`
whenever a campaign uses the preset archetype library instead of Zep graph
entities. With nothing to match a generated initial-post `poster_type`
against, *every* initial post fell back to "the agent with the highest
influence" regardless of what type the LLM picked. These tests cover the
`poster_agent_pool` override that fixes this: the event-config LLM prompt
should only offer real archetype types, and `_assign_initial_post_agents`
should match against the real archetype pool instead of an empty list.
"""

from app.config import Config
from app.services.simulation_config_generator import AgentActivityConfig, SimulationConfigGenerator
from app.services.simulation_manager import SimulationManager


def _make_generator(monkeypatch):
monkeypatch.setattr(Config, "LLM_API_KEY", "test-llm-key")
return SimulationConfigGenerator()


def _pool():
return [
AgentActivityConfig(
agent_id=1, entity_uuid="a1", entity_name="skeptic_01", entity_type="skeptic",
influence_weight=0.4,
),
AgentActivityConfig(
agent_id=2, entity_uuid="a2", entity_name="early_adopter_02", entity_type="early_adopter",
influence_weight=0.9,
),
]


def test_generate_event_config_offers_only_archetype_types_when_pool_given(testing_env, monkeypatch):
generator = _make_generator(monkeypatch)
captured = {}

def fake_llm(prompt, system_prompt):
captured["prompt"] = prompt
return {"hot_topics": [], "narrative_direction": "", "initial_posts": [], "reasoning": "ok"}

monkeypatch.setattr(generator, "_call_llm_with_retry", fake_llm)

generator._generate_event_config(
context="ctx", simulation_requirement="req", entities=[], poster_agent_pool=_pool()
)

prompt = captured["prompt"]
assert "skeptic" in prompt
assert "early_adopter" in prompt
# The entity-graph-flow example types must not leak into an archetype run.
assert "Official/University" not in prompt


def test_generate_event_config_falls_back_to_entities_when_no_pool(testing_env, monkeypatch):
generator = _make_generator(monkeypatch)
captured = {}

def fake_llm(prompt, system_prompt):
captured["prompt"] = prompt
return {"hot_topics": [], "narrative_direction": "", "initial_posts": [], "reasoning": "ok"}

monkeypatch.setattr(generator, "_call_llm_with_retry", fake_llm)

generator._generate_event_config(context="ctx", simulation_requirement="req", entities=[])

assert "Official/University" in captured["prompt"]


def test_assign_initial_post_agents_matches_archetype_pool(testing_env, monkeypatch):
generator = _make_generator(monkeypatch)
pool = _pool()

from app.services.simulation_config_generator import EventConfig

event_config = EventConfig(
initial_posts=[{"content": "Not sure this is worth it.", "poster_type": "skeptic"}],
scheduled_events=[], hot_topics=[], narrative_direction="",
)

result = generator._assign_initial_post_agents(event_config, pool)

assert result.initial_posts[0]["poster_agent_id"] == 1 # the skeptic, not the fallback highest-influence agent


def test_assign_initial_post_agents_falls_back_only_on_genuine_mismatch(testing_env, monkeypatch):
generator = _make_generator(monkeypatch)
pool = _pool()

from app.services.simulation_config_generator import EventConfig

event_config = EventConfig(
initial_posts=[{"content": "x", "poster_type": "totally_unknown_type"}],
scheduled_events=[], hot_topics=[], narrative_direction="",
)

result = generator._assign_initial_post_agents(event_config, pool)

# No archetype named "totally_unknown_type" exists in the pool -> falls
# back to the highest-influence agent (agent_id=2), same as before.
assert result.initial_posts[0]["poster_agent_id"] == 2


def test_build_archetype_agent_configs_produces_matching_pool():
archetype_map = [
{"agent_id": 1, "archetype": "skeptic", "username": "skeptic_01"},
{"agent_id": 2, "archetype": "early_adopter", "username": "early_adopter_02"},
]

configs = SimulationManager._build_archetype_agent_configs(archetype_map)

assert [c.entity_type for c in configs] == ["skeptic", "early_adopter"]
assert [c.agent_id for c in configs] == [1, 2]
assert all(isinstance(c, AgentActivityConfig) for c in configs)
Loading