diff --git a/backend/app/services/simulation_config_generator.py b/backend/app/services/simulation_config_generator.py index 8ba6053..5b0abc2 100644 --- a/backend/app/services/simulation_config_generator.py +++ b/backend/app/services/simulation_config_generator.py @@ -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) @@ -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 @@ -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')}") @@ -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") @@ -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)}" @@ -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} @@ -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): {{ diff --git a/backend/app/services/simulation_manager.py b/backend/app/services/simulation_manager.py index 75a06d2..f45a721 100644 --- a/backend/app/services/simulation_manager.py +++ b/backend/app/services/simulation_manager.py @@ -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') @@ -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, @@ -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, @@ -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: @@ -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) diff --git a/backend/tests/test_simulation_config_poster_pool.py b/backend/tests/test_simulation_config_poster_pool.py new file mode 100644 index 0000000..11ade25 --- /dev/null +++ b/backend/tests/test_simulation_config_poster_pool.py @@ -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)