diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py index 008a6089..d63cc15b 100644 --- a/backend/app/api/simulation.py +++ b/backend/app/api/simulation.py @@ -8,6 +8,7 @@ import traceback from flask import request, jsonify, send_file from . import simulation_bp +from .. import get_storage from ..config import Config from ..services.zep_entity_reader import ZepEntityReader from ..services.oasis_profile_generator import OasisProfileGenerator @@ -190,7 +191,7 @@ def create_simulation(): "error": t('api.projectNotFound', id=project_id) }), 404 - graph_id = data.get('graph_id') or project.graph_id + graph_id = data.get('graph_id') or project.get("graph_id") if not graph_id: return jsonify({ "success": False, @@ -436,7 +437,7 @@ def prepare_simulation(): }), 404 # Get simulation requirement - simulation_requirement = project.simulation_requirement or "" + simulation_requirement = project.get("simulation_requirement") or "" if not simulation_requirement: return jsonify({ "success": False, @@ -444,7 +445,7 @@ def prepare_simulation(): }), 400 # Get document text - document_text = ProjectManager.get_extracted_text(state.project_id) or "" + document_text = ProjectManager.get_extracted_text(state.project_id, get_storage()) or "" entity_types_list = data.get('entity_types') use_llm_for_profiles = data.get('use_llm_for_profiles', True) @@ -718,7 +719,7 @@ def get_prepare_status(): "error": t('api.taskNotFound', id=task_id) }), 404 - task_dict = task.to_dict() + task_dict = task task_dict["already_prepared"] = False return jsonify({ @@ -932,10 +933,10 @@ def get_simulation_history(): # Get associated project's file list (up to 3) project = ProjectManager.get_project(sim.project_id) - if project and hasattr(project, 'files') and project.files: + if project and project.get("files"): sim_dict["files"] = [ {"filename": f.get("filename", "Unknown file")} - for f in project.files[:3] + for f in project.get("files", [])[:3] ] else: sim_dict["files"] = [] @@ -1573,8 +1574,8 @@ def start_simulation(): # Try to get from project project = ProjectManager.get_project(state.project_id) if project: - graph_id = project.graph_id - + graph_id = project.get("graph_id") + if not graph_id: return jsonify({ "success": False, @@ -2699,3 +2700,29 @@ def close_simulation_env(): "error": str(e), "traceback": traceback.format_exc() }), 500 + + +# ============== F2-A: Agent CRUD endpoints ============== + +@simulation_bp.route('//agent/', methods=['PATCH']) +def patch_agent(simulation_id: str, user_id: int): + """Update an agent profile (Fase A/B fields). Sets manually_edited=True.""" + try: + fields = request.get_json() or {} + if not fields: + return jsonify({"success": False, "error": t('api.requireFields')}), 400 + + manager = SimulationManager() + try: + updated = manager.patch_agent_profile(simulation_id, user_id, fields) + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 404 + except PermissionError as e: + return jsonify({"success": False, "error": str(e)}), 403 + except LookupError as e: + return jsonify({"success": False, "error": str(e)}), 404 + + return jsonify({"success": True, "data": updated}) + except Exception as e: + logger.error(f"patch_agent failed: {e}") + return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500 diff --git a/backend/app/services/simulation_manager.py b/backend/app/services/simulation_manager.py index df05edf5..5630d1e3 100644 --- a/backend/app/services/simulation_manager.py +++ b/backend/app/services/simulation_manager.py @@ -539,3 +539,57 @@ class SimulationManager: f" - Both platforms in parallel: python {scripts_dir}/run_parallel_simulation.py --config {config_path}" ) } + + def patch_agent_profile(self, simulation_id: str, user_id: int, fields: dict) -> dict: + """ + Update an agent's profile fields and set manually_edited=True. + Raises ValueError if simulation not found. + Raises PermissionError if simulation status is running or completed. + Raises LookupError if agent user_id not found. + Uses atomic write: backup → write → delete backup on success, restore on failure. + """ + state = self.get_simulation(simulation_id) + if not state: + raise ValueError(f"Simulation {simulation_id} not found") + + immutable = {SimulationStatus.RUNNING, SimulationStatus.COMPLETED} + if state.status in immutable: + raise PermissionError(f"Cannot edit agent while simulation is {state.status.value}") + + sim_dir = self._get_simulation_dir(simulation_id) + profiles_file = os.path.join(sim_dir, "reddit_profiles.json") + backup_file = profiles_file + ".bak" + + if not os.path.exists(profiles_file): + raise FileNotFoundError(f"reddit_profiles.json not found for {simulation_id}") + + with open(profiles_file, 'r', encoding='utf-8') as f: + profiles = json.load(f) + + target = next((p for p in profiles if p.get("user_id") == user_id), None) + if target is None: + raise LookupError(f"Agent user_id={user_id} not found in simulation {simulation_id}") + + allowed = { + "name", "bio", "persona", "age", "gender", "mbti", + "country", "profession", "interested_topics", "stance", "sentiment_bias", + "posts_per_hour", "comments_per_hour", "active_hours", + "response_delay_min", "response_delay_max", "activity_level", "influence_weight", + } + for k, v in fields.items(): + if k in allowed: + target[k] = v + target["manually_edited"] = True + + 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) + os.remove(backup_file) + except Exception: + shutil.copy2(backup_file, profiles_file) + os.remove(backup_file) + raise + + return target diff --git a/backend/tests/test_simulation_agent_api.py b/backend/tests/test_simulation_agent_api.py new file mode 100644 index 00000000..fc1889a0 --- /dev/null +++ b/backend/tests/test_simulation_agent_api.py @@ -0,0 +1,86 @@ +import json +import os +import pytest +from flask import Flask + + +@pytest.fixture +def app(): + os.environ.setdefault("SECRET_KEY", "test") + from backend.app import create_app + application = create_app({'TESTING': True, 'WTF_CSRF_ENABLED': False}) + return application + + +@pytest.fixture +def client(app): + with app.test_client() as c: + yield c + + +@pytest.fixture +def sim_with_profiles(tmp_path, monkeypatch): + """Creates a simulation directory with a minimal state.json and reddit_profiles.json""" + from backend.app.services import simulation_manager as sm_module + monkeypatch.setattr(sm_module.SimulationManager, 'SIMULATION_DATA_DIR', str(tmp_path)) + + sim_id = "sim_test001" + sim_dir = tmp_path / sim_id + sim_dir.mkdir() + + state = { + "simulation_id": sim_id, + "project_id": "proj_test", + "graph_id": "g1", + "status": "profiles_ready", + "entities_count": 2, + "profiles_count": 2, + "entity_types": [], + "config_generated": False, + "config_reasoning": "", + "current_round": 0, + "twitter_status": "not_started", + "reddit_status": "not_started", + "created_at": "2026-01-01T00:00:00", + "updated_at": "2026-01-01T00:00:00", + "error": None, + "parent_simulation_id": None, + "graph_id_simulation": None, + "enable_twitter": True, + "enable_reddit": True, + } + (sim_dir / "state.json").write_text(json.dumps(state)) + + profiles = [ + {"user_id": 0, "user_name": "alice", "name": "Alice", "bio": "Original bio", + "persona": "Curious", "manually_edited": False}, + {"user_id": 1, "user_name": "bob", "name": "Bob", "bio": "Bob bio", + "persona": "Bold", "manually_edited": False}, + ] + (sim_dir / "reddit_profiles.json").write_text(json.dumps(profiles)) + return sim_id + + +def test_patch_agent_updates_bio(client, sim_with_profiles): + sim_id = sim_with_profiles + resp = client.patch(f"/api/simulation/{sim_id}/agent/0", json={"bio": "Updated bio"}) + assert resp.status_code == 200 + data = resp.get_json() + assert data["success"] is True + assert data["data"]["bio"] == "Updated bio" + assert data["data"]["manually_edited"] is True + + +def test_patch_agent_sets_manually_edited(client, sim_with_profiles, tmp_path): + sim_id = sim_with_profiles + client.patch(f"/api/simulation/{sim_id}/agent/0", json={"bio": "New bio"}) + sim_dir = tmp_path / sim_id + profiles = json.loads((sim_dir / "reddit_profiles.json").read_text()) + assert profiles[0]["manually_edited"] is True + assert profiles[1]["manually_edited"] is False # untouched + + +def test_patch_agent_not_found(client, sim_with_profiles): + sim_id = sim_with_profiles + resp = client.patch(f"/api/simulation/{sim_id}/agent/99", json={"bio": "x"}) + assert resp.status_code == 404