diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py index 3aa4422a..6ff6ecf9 100644 --- a/backend/app/api/simulation.py +++ b/backend/app/api/simulation.py @@ -2912,3 +2912,232 @@ def clone_simulation(simulation_id: str): except Exception as e: logger.error(f"clone_simulation failed: {e}") return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500 + + +@simulation_bp.route('//agent', methods=['POST']) +def create_agent(simulation_id: str): + """ + Task 8 — Create a new agent from an entity UUID. + + Requires status in {profiles_ready, created}. + Runs async; returns task_id for polling. + """ + import json + import threading + from ..models.task import TaskManager, TaskStatus + + try: + data = request.get_json() or {} + source_entity_uuid = data.get("source_entity_uuid") + if not source_entity_uuid: + return jsonify({"success": False, "error": t('api.requireFields')}), 400 + + extra_instructions = data.get("extra_instructions") + + manager = SimulationManager() + state = manager.get_simulation(simulation_id) + if not state: + return jsonify({"success": False, "error": t('api.simulationNotFound', id=simulation_id)}), 404 + + allowed_statuses = {SimulationStatus.PROFILES_READY, SimulationStatus.CREATED} + if state.status not in allowed_statuses: + return jsonify({ + "success": False, + "error": t('api.requireProfilesReady', status=state.status.value) + }), 400 + + task_manager = TaskManager() + task_id = task_manager.create_task( + task_type="create_agent", + metadata={"simulation_id": simulation_id, "source_entity_uuid": source_entity_uuid} + ) + + current_locale = get_locale() + + def run_create_agent(): + set_locale(current_locale) + try: + task_manager.update_task(task_id, status=TaskStatus.PROCESSING, progress=0, + message=t('progress.generatingProfile')) + + sim_dir = manager._get_simulation_dir(simulation_id) + profiles_file = os.path.join(sim_dir, "reddit_profiles.json") + + # Fetch entity + reader = ZepEntityReader() + entity = reader.get_entity_with_context(state.graph_id, source_entity_uuid) + if not entity: + raise ValueError(f"Entity not found: {source_entity_uuid}") + + # Read current profiles + with open(profiles_file, 'r', encoding='utf-8') as f: + profiles = json.load(f) + + # Check for duplicate source_entity_uuid + for p in profiles: + if p.get("source_entity_uuid") == source_entity_uuid: + raise ValueError(f"Agent with source_entity_uuid '{source_entity_uuid}' already exists") + + # Compute next user_id + next_user_id = max((p.get("user_id", -1) for p in profiles), default=-1) + 1 + + # Generate profile + gen = OasisProfileGenerator(graph_id=state.graph_id) + profile = gen.generate_profile_from_entity(entity, extra_instructions=extra_instructions) + profile.user_id = next_user_id + + # Convert to dict and append + profile_dict = profile.to_reddit_format() + profile_dict["user_id"] = next_user_id + profile_dict["source_entity_uuid"] = profile.source_entity_uuid + profile_dict["source_entity_type"] = profile.source_entity_type + profile_dict["manually_edited"] = False + + profiles.append(profile_dict) + + # Atomic write: backup → write → delete backup + backup_file = profiles_file + ".bak" + if os.path.exists(profiles_file): + import shutil + shutil.copy2(profiles_file, backup_file) + try: + with open(profiles_file, 'w', encoding='utf-8') as f: + json.dump(profiles, f, ensure_ascii=False, indent=2) + if os.path.exists(backup_file): + os.remove(backup_file) + except Exception as write_err: + # Restore from backup on failure + if os.path.exists(backup_file): + import shutil + shutil.copy2(backup_file, profiles_file) + raise write_err + + # Update profiles_count in state + state2 = manager.get_simulation(simulation_id) + if state2: + state2.profiles_count = len(profiles) + manager._save_simulation_state(state2) + + task_manager.complete_task(task_id, result={"user_id": next_user_id}) + except Exception as e: + logger.error(f"create_agent background failed: {e}") + task_manager.fail_task(task_id, str(e)) + + threading.Thread(target=run_create_agent, daemon=True).start() + + return jsonify({"success": True, "data": {"simulation_id": simulation_id, "task_id": task_id}}) + except Exception as e: + logger.error(f"create_agent endpoint error: {e}") + return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500 + + +@simulation_bp.route('//agent//regenerate', methods=['POST']) +def regenerate_agent(simulation_id: str, user_id: int): + """ + Task 9 — Regenerate an agent's profile from its source entity. + + Requires status == profiles_ready and the agent must have source_entity_uuid. + Runs async; returns task_id for polling. + """ + import json + import threading + from ..models.task import TaskManager, TaskStatus + + try: + data = request.get_json() or {} + extra_instructions = data.get("extra_instructions") + + manager = SimulationManager() + state = manager.get_simulation(simulation_id) + if not state: + return jsonify({"success": False, "error": t('api.simulationNotFound', id=simulation_id)}), 404 + + if state.status != SimulationStatus.PROFILES_READY: + return jsonify({ + "success": False, + "error": t('api.requireProfilesReady', status=state.status.value) + }), 400 + + task_manager = TaskManager() + task_id = task_manager.create_task( + task_type="regenerate_agent", + metadata={"simulation_id": simulation_id, "user_id": user_id} + ) + + current_locale = get_locale() + + def run_regenerate_agent(): + set_locale(current_locale) + try: + task_manager.update_task(task_id, status=TaskStatus.PROCESSING, progress=0, + message=t('progress.generatingProfile')) + + sim_dir = manager._get_simulation_dir(simulation_id) + profiles_file = os.path.join(sim_dir, "reddit_profiles.json") + + with open(profiles_file, 'r', encoding='utf-8') as f: + profiles = json.load(f) + + # Find the agent + agent_idx = None + agent = None + for i, p in enumerate(profiles): + if p.get("user_id") == user_id: + agent_idx = i + agent = p + break + + if agent_idx is None: + raise LookupError(f"Agent with user_id {user_id} not found") + + source_entity_uuid = agent.get("source_entity_uuid") + if not source_entity_uuid: + raise ValueError(f"Agent {user_id} has no source_entity_uuid — cannot regenerate") + + # Fetch entity + reader = ZepEntityReader() + entity = reader.get_entity_with_context(state.graph_id, source_entity_uuid) + if not entity: + raise ValueError(f"Entity not found: {source_entity_uuid}") + + # Generate new profile + gen = OasisProfileGenerator(graph_id=state.graph_id) + new_profile = gen.generate_profile_from_entity(entity, extra_instructions=extra_instructions) + new_profile.user_id = user_id + + # Build dict, preserving user_id and resetting manually_edited + new_profile_dict = new_profile.to_reddit_format() + new_profile_dict["user_id"] = user_id + new_profile_dict["source_entity_uuid"] = new_profile.source_entity_uuid + new_profile_dict["source_entity_type"] = new_profile.source_entity_type + new_profile_dict["manually_edited"] = False + + profiles[agent_idx] = new_profile_dict + + # Atomic write + backup_file = profiles_file + ".bak" + if os.path.exists(profiles_file): + import shutil + shutil.copy2(profiles_file, backup_file) + try: + with open(profiles_file, 'w', encoding='utf-8') as f: + json.dump(profiles, f, ensure_ascii=False, indent=2) + if os.path.exists(backup_file): + os.remove(backup_file) + except Exception as write_err: + if os.path.exists(backup_file): + import shutil + shutil.copy2(backup_file, profiles_file) + raise write_err + + task_manager.complete_task(task_id, result={"user_id": user_id}) + except Exception as e: + logger.error(f"regenerate_agent background failed: {e}") + task_manager.fail_task(task_id, str(e)) + + threading.Thread(target=run_regenerate_agent, daemon=True).start() + + return jsonify({"success": True, "data": {"simulation_id": simulation_id, "task_id": task_id}}) + except Exception as e: + logger.error(f"regenerate_agent endpoint error: {e}") + return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500 diff --git a/backend/app/services/oasis_profile_generator.py b/backend/app/services/oasis_profile_generator.py index e1ec54c5..dec288d0 100644 --- a/backend/app/services/oasis_profile_generator.py +++ b/backend/app/services/oasis_profile_generator.py @@ -231,17 +231,40 @@ class OasisProfileGenerator: logger.warning(f"Zep client initialisation failed: {e}") def generate_profile_from_entity( + self, + entity: EntityNode, + extra_instructions: Optional[str] = None, + ) -> OasisAgentProfile: + """ + Generate an OASIS Agent Profile from a Zep entity (public API). + + The returned profile has user_id=0 as a placeholder; callers must + reassign user_id after insertion into the profiles list. + + Args: + entity: Zep entity node + extra_instructions: Optional extra instructions for the LLM persona + + Returns: + OasisAgentProfile + """ + return self._generate_single_profile(entity, user_id=0, + extra_instructions=extra_instructions) + + def _generate_single_profile( self, entity: EntityNode, user_id: int, + extra_instructions: Optional[str] = None, use_llm: bool = True ) -> OasisAgentProfile: """ - Generate an OASIS Agent Profile from a Zep entity. + Internal: generate an OASIS Agent Profile for a single entity. Args: entity: Zep entity node user_id: User ID (for OASIS) + extra_instructions: Optional extra instructions injected into the LLM prompt use_llm: Whether to use an LLM to generate a detailed persona Returns: @@ -975,7 +998,7 @@ Important: entity_type = entity.get_entity_type() or "Entity" try: - profile = self.generate_profile_from_entity( + profile = self._generate_single_profile( entity=entity, user_id=idx, use_llm=use_llm diff --git a/backend/tests/test_simulation_agent_api.py b/backend/tests/test_simulation_agent_api.py index 1d88b03f..b45dfa7e 100644 --- a/backend/tests/test_simulation_agent_api.py +++ b/backend/tests/test_simulation_agent_api.py @@ -168,3 +168,74 @@ def test_patch_config_updates_total_hours(client, sim_prepared): data = resp.get_json() assert data["success"] is True assert data["data"]["time_config"]["total_simulation_hours"] == 48 + + +def test_create_agent_adds_to_profiles(client, sim_with_profiles, monkeypatch): + sim_id = sim_with_profiles + + import backend.app.services.simulation_manager as sm_module + from backend.app.services.zep_entity_reader import EntityNode + + fake_entity = EntityNode( + uuid="uuid_carol", name="Carol", labels=["Person", "Entity"], + summary="Carol is a scientist", attributes={} + ) + + def fake_get_entity(self, graph_id, uuid_): + return fake_entity + + monkeypatch.setattr( + "backend.app.services.zep_entity_reader.ZepEntityReader.get_entity_with_context", + fake_get_entity + ) + + from backend.app.services.oasis_profile_generator import OasisAgentProfile + fake_profile = OasisAgentProfile(user_id=99, user_name="carol", name="Carol", + bio="Carol bio", persona="Scientist", + source_entity_uuid="uuid_carol") + + monkeypatch.setattr( + "backend.app.services.oasis_profile_generator.OasisProfileGenerator.generate_profile_from_entity", + lambda self, entity, extra_instructions=None: fake_profile + ) + + resp = client.post(f"/api/simulation/{sim_id}/agent", + json={"source_entity_uuid": "uuid_carol", "extra_instructions": "Make her skeptical"}) + assert resp.status_code == 200 + data = resp.get_json() + assert data["success"] is True + assert "task_id" in data["data"] + + +def test_regenerate_agent_returns_task_id(client, sim_with_profiles, monkeypatch): + sim_id = sim_with_profiles + from backend.app.services.zep_entity_reader import EntityNode + fake_entity = EntityNode(uuid="uuid_alice", name="Alice", labels=["Entity"], summary="", attributes={}) + monkeypatch.setattr( + "backend.app.services.zep_entity_reader.ZepEntityReader.get_entity_with_context", + lambda self, g, u: fake_entity + ) + from backend.app.services.oasis_profile_generator import OasisAgentProfile + fake_profile = OasisAgentProfile(user_id=0, user_name="alice2", name="Alice2", + bio="New bio", persona="Skeptic", + source_entity_uuid="uuid_alice") + monkeypatch.setattr( + "backend.app.services.oasis_profile_generator.OasisProfileGenerator.generate_profile_from_entity", + lambda self, entity, extra_instructions=None: fake_profile + ) + + # Add source_entity_uuid to the first profile in the fixture's sim directory + import json as _j + from pathlib import Path + from backend.app.services.simulation_manager import SimulationManager + sim_dir = Path(SimulationManager.SIMULATION_DATA_DIR) / sim_id + profiles = _j.loads((sim_dir / "reddit_profiles.json").read_text()) + profiles[0]["source_entity_uuid"] = "uuid_alice" + (sim_dir / "reddit_profiles.json").write_text(_j.dumps(profiles)) + + resp = client.post(f"/api/simulation/{sim_id}/agent/0/regenerate", + json={"extra_instructions": "Make her skeptical"}) + assert resp.status_code == 200 + data = resp.get_json() + assert data["success"] is True + assert "task_id" in data["data"]