# F2-A+B: Agents Configurables i Paràmetres de Simulació — Pla d'Implementació > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Transformar el Step 2 en un flux de tres fases interactiu: generació i edició d'agents (Fase A), edició de paràmetres de comportament/simulació (Fase B) i aïllament de grafs per simulació (Subsistema 5). **Architecture:** El backend afegeix nous endpoints REST a `simulation.py` i nou mètode `clone_graph` a `graphiti_backend.py`. El model de simulació guanya dos nous camps (`parent_simulation_id`, `graph_id_simulation`). El frontend refactoritza `Step2EnvSetup.vue` en dues fases visuals amb confirmació explícita de l'usuari entre elles. **Tech Stack:** Flask (Python), Vue 3 + Composition API, SQLAlchemy (SQLite), Neo4j/APOC (Graphiti backend), vue-i18n --- ## Mapa de fitxers ### Backend (crear o modificar) | Fitxer | Acció | Responsabilitat | |--------|-------|-----------------| | `backend/app/services/simulation_manager.py` | Modificar | Afegir `PROFILES_READY`, `CONFIGURING` a `SimulationStatus`; afegir `parent_simulation_id`, `graph_id_simulation` a `SimulationState`; mètodes `patch_agent_profile`, `delete_agent_profile`, `generate_config_async`, `clone_simulation` | | `backend/app/services/zep_entity_reader.py` | Modificar | Afegir `get_entities_by_connectivity(graph_id, max_n)` | | `backend/app/services/oasis_profile_generator.py` | Modificar | Exposar `generate_profile_from_entity(entity, extra_instructions)` com a mètode públic | | `backend/app/graph/graphiti_backend.py` | Modificar | Afegir `clone_graph(src_group_id, dst_group_id)` i `delete_graph(group_id)` | | `backend/app/api/simulation.py` | Modificar | Afegir 7 nous endpoints: PATCH agent, DELETE agent, POST agent, POST agent/regen, POST generate-config, PATCH config, POST clone; modificar POST start per a `graph_id_simulation` | | `backend/app/api/report.py` | Modificar | Passar `graph_id_simulation` al ReportAgent si existeix | | `backend/tests/test_simulation_agent_api.py` | Crear | Tests integració dels nous endpoints d'agent | | `backend/tests/test_simulation_clone.py` | Crear | Tests integració clone i graph isolation | ### Frontend (modificar) | Fitxer | Acció | Responsabilitat | |--------|-------|-----------------| | `frontend/src/components/Step2EnvSetup.vue` | Modificar | Refactoritzar en Fase A + Fase B; selector N agents; modal edició/regeneració/creació/eliminació agent | | `frontend/src/components/Step3Simulation.vue` | Modificar | `enable_graph_memory_update` com a checkbox (default true) | | `frontend/src/api/simulation.js` | Modificar | Afegir les 7 noves crides API | | `locales/en.json` | Modificar | Claus noves per a Fase A/B, errors, confirmacions | | `locales/zh.json` | Modificar | Traduccions xineses corresponents | --- ## Task 1: Nous estats i camps a `SimulationState` **Files:** - Modify: `backend/app/services/simulation_manager.py:25-35` (SimulationStatus enum) - Modify: `backend/app/services/simulation_manager.py:43-77` (SimulationState dataclass) - Test: `backend/tests/test_simulation_manager_states.py` - [ ] **Step 1: Escriure el test que falla** ```python # backend/tests/test_simulation_manager_states.py import pytest from backend.app.services.simulation_manager import SimulationStatus, SimulationState def test_profiles_ready_status_exists(): assert SimulationStatus.PROFILES_READY == "profiles_ready" def test_configuring_status_exists(): assert SimulationStatus.CONFIGURING == "configuring" def test_simulation_state_has_parent_id(): state = SimulationState(simulation_id="sim_x", project_id="p", graph_id="g") assert hasattr(state, 'parent_simulation_id') assert state.parent_simulation_id is None def test_simulation_state_has_graph_id_simulation(): state = SimulationState(simulation_id="sim_x", project_id="p", graph_id="g") assert hasattr(state, 'graph_id_simulation') assert state.graph_id_simulation is None def test_to_dict_includes_new_fields(): state = SimulationState(simulation_id="sim_x", project_id="p", graph_id="g") d = state.to_dict() assert 'parent_simulation_id' in d assert 'graph_id_simulation' in d ``` - [ ] **Step 2: Executar el test per verificar que falla** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_manager_states.py -v ``` Expected: FAIL — `SimulationStatus` no té `PROFILES_READY` ni `CONFIGURING` - [ ] **Step 3: Implementar els canvis a `simulation_manager.py`** Localitza `class SimulationStatus(str, Enum):` (línia ~25) i afegeix els nous estats: ```python class SimulationStatus(str, Enum): """Simulation status""" CREATED = "created" PREPARING = "preparing" PROFILES_READY = "profiles_ready" # NEW: agents generated, awaiting user confirmation CONFIGURING = "configuring" # NEW: generating behavior config async READY = "ready" RUNNING = "running" PAUSED = "paused" STOPPED = "stopped" COMPLETED = "completed" FAILED = "failed" ``` Localitza `@dataclass\nclass SimulationState:` i afegeix dos nous camps opcionals just després de `error: Optional[str] = None`: ```python # New fields for F2-A+B parent_simulation_id: Optional[str] = None # set when cloned from another simulation graph_id_simulation: Optional[str] = None # per-simulation Neo4j group_id (cloned from graph_id_document) ``` Actualitza `to_dict()` i `to_simple_dict()` per incloure els nous camps: ```python def to_dict(self) -> Dict[str, Any]: return { # ... camps existents ... "error": self.error, "parent_simulation_id": self.parent_simulation_id, "graph_id_simulation": self.graph_id_simulation, } def to_simple_dict(self) -> Dict[str, Any]: return { # ... camps existents ... "error": self.error, "parent_simulation_id": self.parent_simulation_id, "graph_id_simulation": self.graph_id_simulation, } ``` Actualitza `_load_simulation_state()` per llegir els nous camps: ```python state = SimulationState( # ... camps existents ... error=data.get("error"), parent_simulation_id=data.get("parent_simulation_id"), graph_id_simulation=data.get("graph_id_simulation"), ) ``` - [ ] **Step 4: Executar el test per verificar que passa** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_manager_states.py -v ``` Expected: PASS (5 tests) - [ ] **Step 5: Commit** ```bash git add backend/app/services/simulation_manager.py backend/tests/test_simulation_manager_states.py git commit -m "feat(simulation): add profiles_ready/configuring states and parent_simulation_id/graph_id_simulation fields" ``` --- ## Task 2: `PATCH /api/simulation/{sim_id}/agent/{user_id}` — edició d'agent **Files:** - Modify: `backend/app/api/simulation.py` (afegir ruta) - Modify: `backend/app/services/simulation_manager.py` (afegir mètode) - Test: `backend/tests/test_simulation_agent_api.py` - [ ] **Step 1: Escriure el test que falla** ```python # backend/tests/test_simulation_agent_api.py import json import os import pytest from backend.app import create_app @pytest.fixture def client(): app = create_app({'TESTING': True}) 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.simulation_manager import SimulationManager, SimulationStatus monkeypatch.setenv("OASIS_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, "parent_simulation_id": None, "graph_id_simulation": None, } (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): sim_id = sim_with_profiles client.patch(f"/api/simulation/{sim_id}/agent/0", json={"bio": "New bio"}) # Re-read profiles file import json as _json from pathlib import Path import os sim_dir = Path(os.environ["OASIS_SIMULATION_DATA_DIR"]) / 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 ``` - [ ] **Step 2: Executar per verificar que falla** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_agent_api.py::test_patch_agent_updates_bio -v ``` Expected: FAIL — endpoint no existeix (404) - [ ] **Step 3: Afegir `patch_agent_profile()` a `SimulationManager`** Al final de la classe `SimulationManager` (al fitxer `simulation_manager.py`), afegeix: ```python 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, agent not found, or status is immutable. 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) # Find the agent 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}") # Apply allowed Fase A fields allowed = { "name", "bio", "persona", "age", "gender", "mbti", "country", "profession", "interested_topics", "stance", "sentiment_bias", # Fase B behavior fields "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 # Atomic write 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 ``` - [ ] **Step 4: Afegir l'endpoint `PATCH //agent/` a `simulation.py`** A la secció `# ============== Simulation management endpoints ==============` de `simulation.py`, afegeix just abans del tancament del fitxer: ```python # ============== 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 ``` - [ ] **Step 5: Executar els tests** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_agent_api.py -v ``` Expected: PASS (3 tests nous) - [ ] **Step 6: Commit** ```bash git add backend/app/api/simulation.py backend/app/services/simulation_manager.py backend/tests/test_simulation_agent_api.py git commit -m "feat(simulation): add PATCH /simulation/{id}/agent/{user_id} endpoint" ``` --- ## Task 3: `DELETE /api/simulation/{sim_id}/agent/{user_id}` — eliminació d'agent **Files:** - Modify: `backend/app/api/simulation.py` - Modify: `backend/app/services/simulation_manager.py` - Test: `backend/tests/test_simulation_agent_api.py` (ampliar) - [ ] **Step 1: Afegir test que falla** Afegeix a `backend/tests/test_simulation_agent_api.py`: ```python def test_delete_agent_removes_from_profiles(client, sim_with_profiles): sim_id = sim_with_profiles resp = client.delete(f"/api/simulation/{sim_id}/agent/1") assert resp.status_code == 200 data = resp.get_json() assert data["success"] is True # Verify file import json as _json from pathlib import Path import os sim_dir = Path(os.environ["OASIS_SIMULATION_DATA_DIR"]) / sim_id profiles = _json.loads((sim_dir / "reddit_profiles.json").read_text()) assert all(p["user_id"] != 1 for p in profiles) assert len(profiles) == 1 def test_delete_agent_not_found(client, sim_with_profiles): sim_id = sim_with_profiles resp = client.delete(f"/api/simulation/{sim_id}/agent/99") assert resp.status_code == 404 ``` - [ ] **Step 2: Executar per verificar que falla** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_agent_api.py::test_delete_agent_removes_from_profiles -v ``` Expected: FAIL — endpoint no existeix - [ ] **Step 3: Afegir `delete_agent_profile()` a `SimulationManager`** ```python def delete_agent_profile(self, simulation_id: str, user_id: int) -> None: """Remove an agent from reddit_profiles.json. Atomic write.""" 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 delete 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" with open(profiles_file, 'r', encoding='utf-8') as f: profiles = json.load(f) original_len = len(profiles) profiles = [p for p in profiles if p.get("user_id") != user_id] if len(profiles) == original_len: raise LookupError(f"Agent user_id={user_id} not found") 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 ``` - [ ] **Step 4: Afegir l'endpoint `DELETE //agent/` a `simulation.py`** ```python @simulation_bp.route('//agent/', methods=['DELETE']) def delete_agent(simulation_id: str, user_id: int): """Remove an agent from the simulation (Fase A only).""" try: manager = SimulationManager() try: manager.delete_agent_profile(simulation_id, user_id) 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": {"deleted_user_id": user_id}}) except Exception as e: logger.error(f"delete_agent failed: {e}") return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500 ``` - [ ] **Step 5: Executar els tests** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_agent_api.py -v ``` Expected: PASS (5 tests) - [ ] **Step 6: Commit** ```bash git add backend/app/api/simulation.py backend/app/services/simulation_manager.py backend/tests/test_simulation_agent_api.py git commit -m "feat(simulation): add DELETE /simulation/{id}/agent/{user_id} endpoint" ``` --- ## Task 4: `POST /api/simulation/{sim_id}/generate-config` — transició Fase A → B **Files:** - Modify: `backend/app/api/simulation.py` - Modify: `backend/app/services/simulation_manager.py` - Test: `backend/tests/test_simulation_agent_api.py` (ampliar) - [ ] **Step 1: Afegir test que falla** ```python def test_generate_config_changes_status_to_configuring(client, sim_with_profiles, monkeypatch): sim_id = sim_with_profiles # Mock SimulationConfigGenerator to avoid LLM calls import backend.app.services.simulation_manager as sm_module called = [] def fake_generate(self, *a, **kw): called.append(True) return type('SP', (), {'to_dict': lambda s: {}})() monkeypatch.setattr( "backend.app.services.simulation_config_generator.SimulationConfigGenerator.generate_simulation_parameters", fake_generate ) resp = client.post(f"/api/simulation/{sim_id}/generate-config", json={}) assert resp.status_code == 200 data = resp.get_json() assert data["success"] is True assert "task_id" in data["data"] ``` - [ ] **Step 2: Executar per verificar que falla** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_agent_api.py::test_generate_config_changes_status_to_configuring -v ``` Expected: FAIL — endpoint no existeix - [ ] **Step 3: Afegir l'endpoint `POST //generate-config` a `simulation.py`** ```python @simulation_bp.route('//generate-config', methods=['POST']) def generate_config(simulation_id: str): """ Transition from Fase A to Fase B. Validates status=profiles_ready, changes to configuring, starts async config generation. Returns task_id for polling. """ import threading from ..models.task import TaskManager, TaskStatus from ..services.simulation_config_generator import SimulationConfigGenerator try: 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 project = ProjectManager.get_project(state.project_id) if not project: return jsonify({"success": False, "error": t('api.projectNotFound', id=state.project_id)}), 404 simulation_requirement = project.get("simulation_requirement") or "" document_text = ProjectManager.get_extracted_text(state.project_id, get_storage()) or "" task_manager = TaskManager() task_id = task_manager.create_task( task_type="generate_config", metadata={"simulation_id": simulation_id} ) state.status = SimulationStatus.CONFIGURING manager._save_simulation_state(state) current_locale = get_locale() def run_generate_config(): set_locale(current_locale) try: task_manager.update_task(task_id, status=TaskStatus.PROCESSING, progress=0, message=t('progress.generatingSimConfig')) # Load current profiles (respects manually_edited agents) 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) from ..services.zep_entity_reader import ZepEntityReader, EntityNode entity_nodes = [] reader = ZepEntityReader() for p in profiles: uuid_ = p.get("source_entity_uuid") if uuid_: try: entity = reader.get_entity_with_context(state.graph_id, uuid_) if entity: entity_nodes.append(entity) except Exception: pass gen = SimulationConfigGenerator(graph_id=state.graph_id) params = gen.generate_simulation_parameters( simulation_requirement=simulation_requirement, document_text=document_text, entities=entity_nodes, ) config_data = params.to_dict() if hasattr(params, 'to_dict') else {} config_file = os.path.join(sim_dir, "simulation_config.json") with open(config_file, 'w', encoding='utf-8') as f: json.dump(config_data, f, ensure_ascii=False, indent=2) # Update state.json state2 = manager.get_simulation(simulation_id) if state2: state2.status = SimulationStatus.READY state2.config_generated = True manager._save_simulation_state(state2) task_manager.complete_task(task_id, result={"status": "prepared"}) except Exception as e: logger.error(f"generate_config failed: {e}") task_manager.fail_task(task_id, str(e)) state2 = manager.get_simulation(simulation_id) if state2: state2.status = SimulationStatus.PROFILES_READY manager._save_simulation_state(state2) threading.Thread(target=run_generate_config, daemon=True).start() return jsonify({"success": True, "data": {"simulation_id": simulation_id, "task_id": task_id}}) except Exception as e: logger.error(f"generate_config endpoint error: {e}") return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500 ``` - [ ] **Step 4: Executar els tests** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_agent_api.py -v ``` Expected: PASS (6 tests) - [ ] **Step 5: Commit** ```bash git add backend/app/api/simulation.py backend/tests/test_simulation_agent_api.py git commit -m "feat(simulation): add POST /simulation/{id}/generate-config for Fase A→B transition" ``` --- ## Task 5: `PATCH /api/simulation/{sim_id}/config` — edició paràmetres globals (Fase B) **Files:** - Modify: `backend/app/api/simulation.py` - Modify: `backend/app/services/simulation_manager.py` - Test: `backend/tests/test_simulation_agent_api.py` (ampliar) - [ ] **Step 1: Afegir test que falla** Afegeix a `backend/tests/test_simulation_agent_api.py`: ```python @pytest.fixture def sim_prepared(tmp_path, monkeypatch): """Creates a simulation with status=ready and a simulation_config.json""" monkeypatch.setenv("OASIS_SIMULATION_DATA_DIR", str(tmp_path)) sim_id = "sim_prepared001" sim_dir = tmp_path / sim_id sim_dir.mkdir() state = { "simulation_id": sim_id, "project_id": "p", "graph_id": "g", "status": "ready", "entities_count": 1, "profiles_count": 1, "entity_types": [], "config_generated": True, "parent_simulation_id": None, "graph_id_simulation": None, } (sim_dir / "state.json").write_text(json.dumps(state)) config = {"time_config": {"total_simulation_hours": 24, "minutes_per_round": 60}, "agent_configs": []} (sim_dir / "simulation_config.json").write_text(json.dumps(config)) return sim_id def test_patch_config_updates_total_hours(client, sim_prepared): sim_id = sim_prepared resp = client.patch(f"/api/simulation/{sim_id}/config", json={"total_simulation_hours": 48}) assert resp.status_code == 200 data = resp.get_json() assert data["success"] is True assert data["data"]["time_config"]["total_simulation_hours"] == 48 ``` - [ ] **Step 2: Executar per verificar que falla** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_agent_api.py::test_patch_config_updates_total_hours -v ``` Expected: FAIL - [ ] **Step 3: Afegir `patch_simulation_config()` a `SimulationManager`** ```python def patch_simulation_config(self, simulation_id: str, fields: dict) -> dict: """ Update top-level simulation config fields. Supported: total_simulation_hours, minutes_per_round, agents_per_hour_min, agents_per_hour_max, following_probability, recsys_type, twitter_config (dict merged), reddit_config (dict merged). Atomic write. """ 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 config while simulation is {state.status.value}") sim_dir = self._get_simulation_dir(simulation_id) config_file = os.path.join(sim_dir, "simulation_config.json") backup_file = config_file + ".bak" if not os.path.exists(config_file): raise FileNotFoundError("simulation_config.json not found") with open(config_file, 'r', encoding='utf-8') as f: config = json.load(f) # time_config fields time_fields = {"total_simulation_hours", "minutes_per_round", "agents_per_hour_min", "agents_per_hour_max"} time_config = config.setdefault("time_config", {}) for k in time_fields: if k in fields: time_config[k] = fields[k] # top-level fields for k in ("following_probability", "recsys_type"): if k in fields: config[k] = fields[k] # nested config merge (twitter_config, reddit_config) for nested in ("twitter_config", "reddit_config"): if nested in fields and isinstance(fields[nested], dict): config.setdefault(nested, {}).update(fields[nested]) import shutil shutil.copy2(config_file, backup_file) try: with open(config_file, 'w', encoding='utf-8') as f: json.dump(config, f, ensure_ascii=False, indent=2) os.remove(backup_file) except Exception: shutil.copy2(backup_file, config_file) os.remove(backup_file) raise return config ``` - [ ] **Step 4: Afegir l'endpoint `PATCH //config` a `simulation.py`** ```python @simulation_bp.route('//config', methods=['PATCH']) def patch_simulation_config(simulation_id: str): """Update simulation global config parameters (Fase B).""" try: fields = request.get_json() or {} if not fields: return jsonify({"success": False, "error": t('api.requireFields')}), 400 manager = SimulationManager() try: updated = manager.patch_simulation_config(simulation_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 FileNotFoundError as e: return jsonify({"success": False, "error": str(e)}), 404 return jsonify({"success": True, "data": updated}) except Exception as e: logger.error(f"patch_simulation_config failed: {e}") return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500 ``` - [ ] **Step 5: Executar els tests** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_agent_api.py -v ``` Expected: PASS (7 tests) - [ ] **Step 6: Commit** ```bash git add backend/app/api/simulation.py backend/app/services/simulation_manager.py backend/tests/test_simulation_agent_api.py git commit -m "feat(simulation): add PATCH /simulation/{id}/config for Fase B global params" ``` --- ## Task 6: `POST /api/simulation/{sim_id}/clone` — clonació de simulació **Files:** - Modify: `backend/app/api/simulation.py` - Modify: `backend/app/services/simulation_manager.py` - Test: `backend/tests/test_simulation_clone.py` - [ ] **Step 1: Escriure el test que falla** ```python # backend/tests/test_simulation_clone.py import json import pytest from pathlib import Path @pytest.fixture def client(): from backend.app import create_app app = create_app({'TESTING': True}) with app.test_client() as c: yield c @pytest.fixture def completed_sim(tmp_path, monkeypatch): monkeypatch.setenv("OASIS_SIMULATION_DATA_DIR", str(tmp_path)) sim_id = "sim_src001" sim_dir = tmp_path / sim_id sim_dir.mkdir() state = { "simulation_id": sim_id, "project_id": "proj_src", "graph_id": "g1", "status": "completed", "entities_count": 2, "profiles_count": 2, "entity_types": [], "config_generated": True, "parent_simulation_id": None, "graph_id_simulation": None, } (sim_dir / "state.json").write_text(json.dumps(state)) profiles = [ {"user_id": 0, "name": "Alice", "bio": "Bio A", "manually_edited": False}, {"user_id": 1, "name": "Bob", "bio": "Bio B", "manually_edited": True}, ] (sim_dir / "reddit_profiles.json").write_text(json.dumps(profiles)) (sim_dir / "twitter_profiles.csv").write_text("user_id,name\n0,Alice\n1,Bob\n") (sim_dir / "agent_profiles.json").write_text(json.dumps(profiles)) (sim_dir / "simulation_config.json").write_text(json.dumps({"time_config": {}})) return sim_id def test_clone_creates_new_simulation(client, completed_sim, monkeypatch): src_id = completed_sim # Mock ProjectManager to return a minimal project import backend.app.models.project as pm_module monkeypatch.setattr(pm_module.ProjectManager, "get_project", staticmethod(lambda pid: {"project_id": pid, "graph_id": "g1"})) resp = client.post(f"/api/simulation/{src_id}/clone", json={"project_id": "proj_src"}) assert resp.status_code == 200 data = resp.get_json() assert data["success"] is True assert "new_simulation_id" in data["data"] assert data["data"]["parent_simulation_id"] == src_id def test_clone_copies_profiles_not_config(client, completed_sim, monkeypatch): src_id = completed_sim import backend.app.models.project as pm_module monkeypatch.setattr(pm_module.ProjectManager, "get_project", staticmethod(lambda pid: {"project_id": pid, "graph_id": "g1"})) import os sim_data_dir = os.environ["OASIS_SIMULATION_DATA_DIR"] resp = client.post(f"/api/simulation/{src_id}/clone", json={"project_id": "proj_src"}) new_sim_id = resp.get_json()["data"]["new_simulation_id"] new_dir = Path(sim_data_dir) / new_sim_id assert (new_dir / "reddit_profiles.json").exists() assert (new_dir / "twitter_profiles.csv").exists() # simulation_config.json must NOT be copied assert not (new_dir / "simulation_config.json").exists() def test_clone_status_is_profiles_ready(client, completed_sim, monkeypatch): src_id = completed_sim import backend.app.models.project as pm_module monkeypatch.setattr(pm_module.ProjectManager, "get_project", staticmethod(lambda pid: {"project_id": pid, "graph_id": "g1"})) import os, json as _json sim_data_dir = os.environ["OASIS_SIMULATION_DATA_DIR"] resp = client.post(f"/api/simulation/{src_id}/clone", json={"project_id": "proj_src"}) new_sim_id = resp.get_json()["data"]["new_simulation_id"] new_state = _json.loads((Path(sim_data_dir) / new_sim_id / "state.json").read_text()) assert new_state["status"] == "profiles_ready" assert new_state["parent_simulation_id"] == src_id ``` - [ ] **Step 2: Executar per verificar que falla** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_clone.py -v ``` Expected: FAIL — endpoint no existeix - [ ] **Step 3: Afegir `clone_simulation()` a `SimulationManager`** ```python def clone_simulation(self, source_simulation_id: str, project_id: str) -> SimulationState: """ Clone an existing simulation: copy agent profile files, set status=profiles_ready, record parent_simulation_id. Does NOT copy simulation_config.json. """ source_state = self.get_simulation(source_simulation_id) if not source_state: raise ValueError(f"Source simulation {source_simulation_id} not found") if source_state.status == SimulationStatus.CREATED: raise ValueError("Cannot clone a simulation in 'created' status (no profiles yet)") import uuid new_sim_id = f"sim_{uuid.uuid4().hex[:12]}" new_state = SimulationState( simulation_id=new_sim_id, project_id=project_id, graph_id=source_state.graph_id, enable_twitter=source_state.enable_twitter, enable_reddit=source_state.enable_reddit, status=SimulationStatus.PROFILES_READY, entities_count=source_state.entities_count, profiles_count=source_state.profiles_count, entity_types=source_state.entity_types, config_generated=False, parent_simulation_id=source_simulation_id, ) src_dir = self._get_simulation_dir(source_simulation_id) dst_dir = self._get_simulation_dir(new_sim_id) # Copy profile files only (not simulation_config.json) for fname in ("reddit_profiles.json", "twitter_profiles.csv", "agent_profiles.json"): src_file = os.path.join(src_dir, fname) if os.path.exists(src_file): import shutil shutil.copy2(src_file, os.path.join(dst_dir, fname)) self._save_simulation_state(new_state) logger.info(f"Cloned simulation {source_simulation_id} -> {new_sim_id} (project={project_id})") return new_state ``` - [ ] **Step 4: Afegir l'endpoint `POST //clone` a `simulation.py`** ```python @simulation_bp.route('//clone', methods=['POST']) def clone_simulation(simulation_id: str): """Clone a simulation: copy agent profiles, set status=profiles_ready.""" try: data = request.get_json() or {} project_id = data.get('project_id') if not project_id: return jsonify({"success": False, "error": t('api.requireProjectId')}), 400 manager = SimulationManager() try: new_state = manager.clone_simulation(simulation_id, project_id) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 return jsonify({ "success": True, "data": { "new_simulation_id": new_state.simulation_id, "parent_simulation_id": simulation_id, "status": new_state.status.value, } }) except Exception as e: logger.error(f"clone_simulation failed: {e}") return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500 ``` - [ ] **Step 5: Executar els tests** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_clone.py -v ``` Expected: PASS (3 tests) - [ ] **Step 6: Commit** ```bash git add backend/app/api/simulation.py backend/app/services/simulation_manager.py backend/tests/test_simulation_clone.py git commit -m "feat(simulation): add POST /simulation/{id}/clone endpoint with parent_simulation_id" ``` --- ## Task 7: Selector de N agents i `prepare` amb `max_agents` **Files:** - Modify: `backend/app/services/zep_entity_reader.py` - Modify: `backend/app/api/simulation.py` (`prepare_simulation`) - Modify: `backend/app/services/simulation_manager.py` (`prepare_simulation`) - Test: `backend/tests/test_zep_entity_reader.py` (nou) - [ ] **Step 1: Escriure el test que falla** ```python # backend/tests/test_zep_entity_reader.py import pytest from unittest.mock import patch, MagicMock from backend.app.services.zep_entity_reader import ZepEntityReader, EntityNode def _make_entity(uuid, name, edge_count): e = EntityNode(uuid=uuid, name=name, labels=["Person", "Entity"], summary="", attributes={}) e.related_edges = [{}] * edge_count # simulate edge_count edges return e def test_get_entities_by_connectivity_returns_top_n(): reader = ZepEntityReader.__new__(ZepEntityReader) entities = [ _make_entity("u1", "Alice", 10), _make_entity("u2", "Bob", 3), _make_entity("u3", "Carol", 7), _make_entity("u4", "Dave", 1), _make_entity("u5", "Eve", 5), ] with patch.object(reader, 'filter_defined_entities') as mock_filter: from backend.app.services.zep_entity_reader import FilteredEntities mock_filter.return_value = FilteredEntities( entities=entities, entity_types=set(), total_count=5, filtered_count=5 ) result = reader.get_entities_by_connectivity(graph_id="g1", max_n=3) assert len(result) == 3 assert result[0].name == "Alice" # 10 edges — top assert result[1].name == "Carol" # 7 edges assert result[2].name == "Eve" # 5 edges def test_get_entities_by_connectivity_no_limit(): reader = ZepEntityReader.__new__(ZepEntityReader) entities = [_make_entity(f"u{i}", f"E{i}", i) for i in range(5)] with patch.object(reader, 'filter_defined_entities') as mock_filter: from backend.app.services.zep_entity_reader import FilteredEntities mock_filter.return_value = FilteredEntities( entities=entities, entity_types=set(), total_count=5, filtered_count=5 ) result = reader.get_entities_by_connectivity(graph_id="g1", max_n=None) assert len(result) == 5 ``` - [ ] **Step 2: Executar per verificar que falla** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_zep_entity_reader.py -v ``` Expected: FAIL — mètode no existeix - [ ] **Step 3: Afegir `get_entities_by_connectivity` a `ZepEntityReader`** Al final de la classe `ZepEntityReader` (a `zep_entity_reader.py`), afegeix: ```python def get_entities_by_connectivity( self, graph_id: str, max_n: Optional[int] = None, defined_entity_types: Optional[List[str]] = None, ) -> List[EntityNode]: """ Return entities sorted by edge degree (descending), optionally capped at max_n. Most-connected entities generate richer simulations. """ filtered = self.filter_defined_entities( graph_id=graph_id, defined_entity_types=defined_entity_types, enrich_with_edges=True, ) entities = sorted( filtered.entities, key=lambda e: len(e.related_edges), reverse=True, ) if max_n is not None and max_n > 0: entities = entities[:max_n] return entities ``` - [ ] **Step 4: Afegir `max_agents` a `POST /api/simulation/prepare`** Localitza el paràmetre `entity_types_list = data.get('entity_types')` a la funció `prepare_simulation()` i afegeix just a sota: ```python max_agents = data.get('max_agents') # optional: limit to top-N most-connected entities ``` Localitza la crida a `manager.prepare_simulation(` i afegeix el paràmetre `max_agents=max_agents` a la llista d'arguments. - [ ] **Step 5: Afegir `max_agents` a `SimulationManager.prepare_simulation()`** Localitza la signatura de `def prepare_simulation(` a `simulation_manager.py` i afegeix `max_agents: Optional[int] = None` als paràmetres. Dins del cos, just abans de la lectura d'entitats, afegeix: ```python if max_agents and max_agents > 0: logger.info(f"Limiting entities to top-{max_agents} by connectivity") entities = reader.get_entities_by_connectivity( graph_id=state.graph_id, max_n=max_agents, defined_entity_types=defined_entity_types, ) # Wrap into FilteredEntities for downstream code from .zep_entity_reader import FilteredEntities filtered_entities = FilteredEntities( entities=entities, entity_types={e.get_entity_type() for e in entities if e.get_entity_type()}, total_count=len(entities), filtered_count=len(entities), ) else: filtered_entities = reader.filter_defined_entities(...) # existing call ``` *Nota: el codi existent a `prepare_simulation` fa la crida a `filter_defined_entities`; cal envolicar-la en un `else` o substituir-la pel bloc anterior.* - [ ] **Step 6: Executar els tests** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_zep_entity_reader.py -v ``` Expected: PASS (2 tests) - [ ] **Step 7: Commit** ```bash git add backend/app/services/zep_entity_reader.py backend/app/api/simulation.py backend/app/services/simulation_manager.py backend/tests/test_zep_entity_reader.py git commit -m "feat(simulation): add max_agents selector via top-N connectivity in prepare" ``` --- ## Task 8: `POST /api/simulation/{sim_id}/agent` — creació d'agent **Files:** - Modify: `backend/app/api/simulation.py` - Modify: `backend/app/services/simulation_manager.py` - Test: `backend/tests/test_simulation_agent_api.py` (ampliar) - [ ] **Step 1: Afegir test que falla** ```python def test_create_agent_adds_to_profiles(client, sim_with_profiles, monkeypatch): sim_id = sim_with_profiles # Mock the entity reader and profile generator 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 ) # Mock profile generator 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"] ``` - [ ] **Step 2: Executar per verificar que falla** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_agent_api.py::test_create_agent_adds_to_profiles -v ``` Expected: FAIL — endpoint no existeix - [ ] **Step 3: Exposar `generate_profile_from_entity` a `OasisProfileGenerator`** Obre `backend/app/services/oasis_profile_generator.py`. Localitza el mètode que genera un perfil per a una entitat (probablement privat o part d'un loop). Afegeix/verifica un mètode públic: ```python def generate_profile_from_entity( self, entity: 'EntityNode', extra_instructions: Optional[str] = None, ) -> 'OasisAgentProfile': """ Generate a single OasisAgentProfile from an EntityNode. Calls the same LLM logic as the batch generator. If extra_instructions is provided, appends them to the generation prompt. """ # Delegate to the existing single-entity generation logic. # The existing code typically calls _generate_profile_with_llm or similar. # Pass extra_instructions as part of the context. return self._generate_single_profile(entity, extra_instructions=extra_instructions) ``` *Nota: si el mètode intern té un nom diferent, renomena la crida. Inspeccioneu el codi existent i ajusteu.* - [ ] **Step 4: Afegir l'endpoint `POST //agent` a `simulation.py`** ```python @simulation_bp.route('//agent', methods=['POST']) def create_agent(simulation_id: str): """ Create a new agent from an existing graph entity (Fase A). Returns task_id for polling (generation is async). """ import threading from ..models.task import TaskManager, TaskStatus from ..services.zep_entity_reader import ZepEntityReader from ..services.oasis_profile_generator import OasisProfileGenerator try: data = request.get_json() or {} source_entity_uuid = data.get('source_entity_uuid') extra_instructions = data.get('extra_instructions', '') if not source_entity_uuid: return jsonify({"success": False, "error": t('api.requireEntityUuid')}), 400 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.cannotCreateAgentInStatus', 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="Generating new agent profile...") reader = ZepEntityReader() entity = reader.get_entity_with_context(state.graph_id, source_entity_uuid) if not entity: task_manager.fail_task(task_id, f"Entity {source_entity_uuid} not found in graph") return # Load existing profiles to check for duplicates and get next user_id 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) # Check entity not already assigned if any(p.get("source_entity_uuid") == source_entity_uuid for p in profiles): task_manager.fail_task(task_id, "Entity already has an agent assigned") return next_user_id = max((p.get("user_id", 0) for p in profiles), default=-1) + 1 gen = OasisProfileGenerator(graph_id=state.graph_id) profile = gen.generate_profile_from_entity(entity, extra_instructions=extra_instructions) profile.user_id = next_user_id profile_dict = profile.to_reddit_format() profile_dict["source_entity_uuid"] = source_entity_uuid profile_dict["manually_edited"] = False profiles.append(profile_dict) import shutil backup = profiles_file + ".bak" shutil.copy2(profiles_file, backup) try: with open(profiles_file, 'w', encoding='utf-8') as f: json.dump(profiles, f, ensure_ascii=False, indent=2) os.remove(backup) except Exception: shutil.copy2(backup, profiles_file) os.remove(backup) raise task_manager.complete_task(task_id, result=profile_dict) 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 ``` - [ ] **Step 5: Executar els tests** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_agent_api.py -v ``` Expected: PASS (8+ tests) - [ ] **Step 6: Commit** ```bash git add backend/app/api/simulation.py backend/app/services/oasis_profile_generator.py backend/tests/test_simulation_agent_api.py git commit -m "feat(simulation): add POST /simulation/{id}/agent for new agent creation" ``` --- ## Task 9: `POST /api/simulation/{sim_id}/agent/{user_id}/regenerate` — regeneració d'agent **Files:** - Modify: `backend/app/api/simulation.py` - Test: `backend/tests/test_simulation_agent_api.py` (ampliar) - [ ] **Step 1: Afegir test que falla** ```python 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 ) # Need source_entity_uuid in profile import json as _j from pathlib import Path import os sim_dir = Path(os.environ["OASIS_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"] ``` - [ ] **Step 2: Executar per verificar que falla** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_agent_api.py::test_regenerate_agent_returns_task_id -v ``` Expected: FAIL - [ ] **Step 3: Afegir l'endpoint `POST //agent//regenerate` a `simulation.py`** ```python @simulation_bp.route('//agent//regenerate', methods=['POST']) def regenerate_agent(simulation_id: str, user_id: int): """ Regenerate a single agent's personality profile. Only available when status=profiles_ready. Returns task_id for polling. """ import threading from ..models.task import TaskManager, TaskStatus from ..services.zep_entity_reader import ZepEntityReader from ..services.oasis_profile_generator import OasisProfileGenerator 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.regenerateRequiresProfilesReady', status=state.status.value) }), 400 # Verify agent exists and has source_entity_uuid 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) target = next((p for p in profiles if p.get("user_id") == user_id), None) if not target: return jsonify({"success": False, "error": t('api.agentNotFound', user_id=user_id)}), 404 source_entity_uuid = target.get("source_entity_uuid") if not source_entity_uuid: return jsonify({"success": False, "error": t('api.agentNoSourceEntity')}), 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(): set_locale(current_locale) try: task_manager.update_task(task_id, status=TaskStatus.PROCESSING, progress=0, message="Regenerating agent profile...") reader = ZepEntityReader() entity = reader.get_entity_with_context(state.graph_id, source_entity_uuid) if not entity: task_manager.fail_task(task_id, f"Entity {source_entity_uuid} not found") return gen = OasisProfileGenerator(graph_id=state.graph_id) new_profile = gen.generate_profile_from_entity(entity, extra_instructions=extra_instructions) new_profile_dict = new_profile.to_reddit_format() new_profile_dict["user_id"] = user_id new_profile_dict["source_entity_uuid"] = source_entity_uuid new_profile_dict["manually_edited"] = False # freshly regenerated # Reload and update profiles with open(profiles_file, 'r', encoding='utf-8') as f: current_profiles = json.load(f) for i, p in enumerate(current_profiles): if p.get("user_id") == user_id: current_profiles[i] = new_profile_dict break import shutil backup = profiles_file + ".bak" shutil.copy2(profiles_file, backup) try: with open(profiles_file, 'w', encoding='utf-8') as f: json.dump(current_profiles, f, ensure_ascii=False, indent=2) os.remove(backup) except Exception: shutil.copy2(backup, profiles_file) os.remove(backup) raise task_manager.complete_task(task_id, result=new_profile_dict) 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, 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 ``` - [ ] **Step 4: Executar els tests** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_simulation_agent_api.py -v ``` Expected: PASS (9+ tests) - [ ] **Step 5: Commit** ```bash git add backend/app/api/simulation.py backend/tests/test_simulation_agent_api.py git commit -m "feat(simulation): add POST /simulation/{id}/agent/{user_id}/regenerate" ``` --- ## Task 10: Subsistema 5 — `clone_graph` i `delete_graph` a `GraphitiBackend` **Files:** - Modify: `backend/app/graph/graphiti_backend.py` - Test: `backend/tests/test_graph_clone.py` (nou) **Nota prèvia:** Aquest task requereix Neo4j/Graphiti actiu. Si l'entorn de test no té Neo4j, els tests han d'usar mocks del driver Neo4j. - [ ] **Step 1: Escriure el test que falla** ```python # backend/tests/test_graph_clone.py import pytest from unittest.mock import AsyncMock, MagicMock, patch def test_clone_graph_executes_two_queries(monkeypatch): """clone_graph should run exactly 2 Cypher queries: one for nodes, one for relationships.""" from backend.app.graph.graphiti_backend import GraphitiBackend backend = GraphitiBackend.__new__(GraphitiBackend) executed_queries = [] async def fake_execute_query(query, parameters=None, **kwargs): executed_queries.append(query) return [] with patch.object(backend, '_execute_neo4j_query', new=fake_execute_query): import asyncio # Use the sync wrapper _run_async if available, or run directly backend._run_sync(backend.clone_graph("src_group", "dst_group")) assert len(executed_queries) == 2 assert "group_id" in executed_queries[0].lower() or "group_id" in executed_queries[1].lower() def test_delete_graph_executes_detach_delete(monkeypatch): """delete_graph should run a DETACH DELETE query.""" from backend.app.graph.graphiti_backend import GraphitiBackend backend = GraphitiBackend.__new__(GraphitiBackend) executed_queries = [] async def fake_execute_query(query, parameters=None, **kwargs): executed_queries.append(query) return [] with patch.object(backend, '_execute_neo4j_query', new=fake_execute_query): backend._run_sync(backend.delete_graph("group_to_delete")) assert any("DETACH DELETE" in q for q in executed_queries) ``` - [ ] **Step 2: Executar per verificar que falla** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_graph_clone.py -v ``` Expected: FAIL — mètodes no existeixen - [ ] **Step 3: Afegir `clone_graph`, `delete_graph` i `_run_sync` a `GraphitiBackend`** Al final de la classe `GraphitiBackend` (a `graphiti_backend.py`), afegeix: ```python def _run_sync(self, coro): """Run an async coroutine synchronously (convenience wrapper for tests and sync callers).""" return _run_async(coro) async def _execute_neo4j_query(self, query: str, parameters: dict = None): """Execute a raw Cypher query against Neo4j using the Graphiti driver.""" # Access the underlying Neo4j driver via graphiti's internal driver driver = self._client.driver async with driver.session() as session: result = await session.run(query, parameters or {}) return await result.data() async def clone_graph(self, src_group_id: str, dst_group_id: str) -> None: """ Clone all nodes and relationships from src_group_id to dst_group_id. Uses APOC for relationship cloning (available on Neo4j Aura Cloud by default). """ logger.info(f"Cloning graph: {src_group_id} -> {dst_group_id}") # Step 1: Clone nodes clone_nodes_query = """ MATCH (n) WHERE n.group_id = $src WITH n, properties(n) AS props CREATE (m) SET m = props SET m.group_id = $dst """ await self._execute_neo4j_query(clone_nodes_query, {"src": src_group_id, "dst": dst_group_id}) # Step 2: Clone relationships (requires APOC) clone_rels_query = """ MATCH (n)-[r]->(m) WHERE n.group_id = $src AND m.group_id = $src MATCH (n2 {uuid: n.uuid, group_id: $dst}) MATCH (m2 {uuid: m.uuid, group_id: $dst}) CALL apoc.create.relationship(n2, type(r), properties(r), m2) YIELD rel SET rel.group_id = $dst RETURN rel """ await self._execute_neo4j_query(clone_rels_query, {"src": src_group_id, "dst": dst_group_id}) logger.info(f"Graph cloned successfully: {src_group_id} -> {dst_group_id}") async def delete_graph(self, group_id: str) -> None: """Delete all nodes (and their relationships) for a given group_id.""" logger.info(f"Deleting graph: {group_id}") delete_query = """ MATCH (n) WHERE n.group_id = $gid DETACH DELETE n """ await self._execute_neo4j_query(delete_query, {"gid": group_id}) logger.info(f"Graph deleted: {group_id}") ``` - [ ] **Step 4: Executar els tests (amb mocks)** ```bash cd /home/ubuntu/dev/MiroFish && python -m pytest backend/tests/test_graph_clone.py -v ``` Expected: PASS (2 tests) - [ ] **Step 5: Commit** ```bash git add backend/app/graph/graphiti_backend.py backend/tests/test_graph_clone.py git commit -m "feat(graph): add clone_graph and delete_graph to GraphitiBackend via APOC" ``` --- ## Task 11: Integrar `clone_graph` a `POST /start` i `graph_id_simulation` al report **Files:** - Modify: `backend/app/api/simulation.py` (funció `start_simulation`) - Modify: `backend/app/api/report.py` - Test: `backend/tests/test_simulation_clone.py` (ampliar) - [ ] **Step 1: Afegir test que falla** ```python # Afegeix a backend/tests/test_simulation_clone.py def test_start_simulation_sets_graph_id_simulation(client, sim_prepared_ready, monkeypatch): """When enable_graph_memory_update=true, start should clone graph and save graph_id_simulation.""" sim_id = sim_prepared_ready # fixture amb status=ready from backend.app.graph import factory as graph_factory mock_backend = MagicMock() mock_backend.clone_graph = AsyncMock(return_value=None) monkeypatch.setattr(graph_factory, "get_graph_backend", lambda: mock_backend) # Also mock SimulationRunner.start_simulation import backend.app.services.simulation_runner as runner_module fake_run_state = MagicMock() fake_run_state.to_dict.return_value = {"runner_status": "running"} monkeypatch.setattr(runner_module.SimulationRunner, "start_simulation", staticmethod(lambda **kw: fake_run_state)) import backend.app.models.project as pm_module monkeypatch.setattr(pm_module.ProjectManager, "get_project", staticmethod(lambda pid: {"project_id": pid, "graph_id": "g1"})) resp = client.post("/api/simulation/start", json={ "simulation_id": sim_id, "enable_graph_memory_update": True, }) assert resp.status_code == 200 data = resp.get_json() assert data["success"] is True # graph_id_simulation should be in response or saved to state # At minimum, clone_graph should have been called mock_backend.clone_graph.assert_called_once() ``` *(Nota: afegir el fixture `sim_prepared_ready` similar a `completed_sim` però amb `status=ready` i `config_generated=True`)* - [ ] **Step 2: Modificar `start_simulation()` a `simulation.py`** Just abans de la crida `run_state = SimulationRunner.start_simulation(...)`, afegeix el bloc de clonació de graf: ```python # Clone graph for per-simulation isolation (if graph memory update is enabled) if enable_graph_memory_update and graph_id: try: graph_id_sim = f"mirofish_{simulation_id}_sim" from ..graph import get_graph_backend graph_backend = get_graph_backend() if hasattr(graph_backend, 'clone_graph'): _run_async_if_needed(graph_backend.clone_graph(graph_id, graph_id_sim)) # Save graph_id_simulation to state state.graph_id_simulation = graph_id_sim manager._save_simulation_state(state) graph_id = graph_id_sim # simulations write to the cloned graph logger.info(f"Graph cloned for simulation: {graph_id_sim}") except Exception as e: logger.warning(f"Graph cloning failed (simulation will use shared graph): {e}") ``` Afegeix la funció helper al mòdul (fora de qualsevol classe): ```python def _run_async_if_needed(coro): """Run a coroutine synchronously from a sync Flask context.""" import asyncio try: loop = asyncio.get_event_loop() if loop.is_running(): import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as pool: future = pool.submit(asyncio.run, coro) return future.result() return loop.run_until_complete(coro) except RuntimeError: return asyncio.run(coro) ``` - [ ] **Step 3: Modificar `report.py` per passar `graph_id_simulation`** Localitza la funció que crida `ReportAgent` a `report.py`. Abans de la crida, afegeix: ```python # Use per-simulation graph if available, else fall back to document graph effective_graph_id = sim_state.graph_id_simulation or sim_state.graph_id # Pass effective_graph_id to ReportAgent instead of the original graph_id ``` *(El canvi exacte depèn de com `report.py` instancia `ReportAgent` — cal llegir la signatura del constructor de `ReportAgent` i adaptar.)* - [ ] **Step 4: Commit** ```bash git add backend/app/api/simulation.py backend/app/api/report.py git commit -m "feat(simulation): clone graph on start when enable_graph_memory_update=true, pass graph_id_simulation to report" ``` --- ## Task 12: Afegir 7 noves crides a `frontend/src/api/simulation.js` **Files:** - Modify: `frontend/src/api/simulation.js` - [ ] **Step 1: Afegir les noves funcions al final del fitxer** ```javascript /** * Update an agent's profile fields (Fase A/B) * @param {string} simulationId * @param {number} userId * @param {Object} fields - partial profile fields to update */ export const patchAgent = (simulationId, userId, fields) => { return service.patch(`/api/simulation/${simulationId}/agent/${userId}`, fields) } /** * Delete an agent from a simulation * @param {string} simulationId * @param {number} userId */ export const deleteAgent = (simulationId, userId) => { return service.delete(`/api/simulation/${simulationId}/agent/${userId}`) } /** * Create a new agent from an existing graph entity * @param {string} simulationId * @param {Object} data - { source_entity_uuid, extra_instructions? } */ export const createAgent = (simulationId, data) => { return requestWithRetry(() => service.post(`/api/simulation/${simulationId}/agent`, data), 3, 1000) } /** * Regenerate an agent's personality profile * @param {string} simulationId * @param {number} userId * @param {Object} data - { extra_instructions? } */ export const regenerateAgent = (simulationId, userId, data = {}) => { return requestWithRetry(() => service.post(`/api/simulation/${simulationId}/agent/${userId}/regenerate`, data), 3, 1000) } /** * Trigger Fase A → Fase B transition (generate behavior config) * @param {string} simulationId */ export const generateConfig = (simulationId) => { return requestWithRetry(() => service.post(`/api/simulation/${simulationId}/generate-config`, {}), 3, 1000) } /** * Update simulation global config parameters (Fase B) * @param {string} simulationId * @param {Object} fields - partial config fields */ export const patchSimulationConfig = (simulationId, fields) => { return service.patch(`/api/simulation/${simulationId}/config`, fields) } /** * Clone a simulation (copy agent profiles, set status=profiles_ready) * @param {string} simulationId - source simulation ID * @param {string} projectId */ export const cloneSimulation = (simulationId, projectId) => { return requestWithRetry(() => service.post(`/api/simulation/${simulationId}/clone`, { project_id: projectId }), 3, 1000) } ``` - [ ] **Step 2: Verificar sintaxi** ```bash cd /home/ubuntu/dev/MiroFish/frontend && node --input-type=module < /dev/null || npx eslint src/api/simulation.js --no-eslintrc --rule '{}' 2>&1 | head -20 ``` - [ ] **Step 3: Commit** ```bash git add frontend/src/api/simulation.js git commit -m "feat(frontend-api): add 7 new simulation API calls for F2-A+B" ``` --- ## Task 13: Traduccions noves per a F2-A+B **Files:** - Modify: `locales/en.json` - Modify: `locales/zh.json` - [ ] **Step 1: Afegir claus a `locales/en.json`** Localitza la secció `"step2"` i afegeix les noves claus: ```json "step2": { "...claus existents...", "agentCount": "Number of Agents", "agentCountHint": "Select entities by graph connectivity (most connected first)", "agentCountWarning": "Fewer than 15 agents may produce less rich simulations", "phaseATitle": "Agent Personalities", "phaseASubtitle": "Review and edit generated agent profiles", "continueToPhaseB": "Continue →", "phaseBTitle": "Simulation Parameters", "phaseBSubtitle": "Edit behavior parameters and simulation settings", "launchSimulation": "Launch Simulation", "editAgent": "Edit", "deleteAgent": "Delete", "deleteAgentConfirm": "Delete this agent? This cannot be undone.", "regenerateAgent": "Regenerate", "regenerateAgentHint": "Extra instructions (optional)", "createAgent": "Add Agent", "createAgentTitle": "Add New Agent", "selectEntityType": "Entity Type", "selectEntity": "Select Entity", "extraInstructions": "Extra Instructions (optional)", "manuallyEditedBadge": "Edited", "generatingConfig": "Generating behavior config...", "cloneFrom": "Clone from previous simulation", "newSimulation": "New simulation", "simulationSource": "Simulation Source", "behaviorParams": "Behavior Parameters", "globalParams": "Global Parameters", "totalHours": "Total Hours", "minutesPerRound": "Minutes per Round", "followingProbability": "Following Probability", "recsysType": "Recommendation System" } ``` Afegeix a la secció `"api"`: ```json "api": { "...claus existents...", "requireFields": "No fields provided", "requireEntityUuid": "source_entity_uuid is required", "requireProfilesReady": "Simulation must be in profiles_ready status (current: {status})", "requireAgentId": "agent user_id is required", "agentNotFound": "Agent user_id={user_id} not found", "agentNoSourceEntity": "Agent has no source_entity_uuid — cannot regenerate", "cannotCreateAgentInStatus": "Cannot create agent when simulation status is {status}", "regenerateRequiresProfilesReady": "Regenerate requires profiles_ready status (current: {status})" } ``` - [ ] **Step 2: Afegir les mateixes claus a `locales/zh.json`** Afegeix les traduccions corresponents a les mateixes seccions: ```json "step2": { "...claus existents...", "agentCount": "智能体数量", "agentCountHint": "按图谱连接度排序(连接最多的优先)", "agentCountWarning": "少于15个智能体可能导致模拟不够丰富", "phaseATitle": "智能体个性", "phaseASubtitle": "查看并编辑生成的智能体档案", "continueToPhaseB": "继续 →", "phaseBTitle": "模拟参数", "phaseBSubtitle": "编辑行为参数和模拟设置", "launchSimulation": "启动模拟", "editAgent": "编辑", "deleteAgent": "删除", "deleteAgentConfirm": "删除此智能体?此操作不可撤销。", "regenerateAgent": "重新生成", "regenerateAgentHint": "额外说明(可选)", "createAgent": "添加智能体", "createAgentTitle": "添加新智能体", "selectEntityType": "实体类型", "selectEntity": "选择实体", "extraInstructions": "额外说明(可选)", "manuallyEditedBadge": "已编辑", "generatingConfig": "正在生成行为配置...", "cloneFrom": "从已有模拟克隆", "newSimulation": "新模拟", "simulationSource": "模拟来源", "behaviorParams": "行为参数", "globalParams": "全局参数", "totalHours": "总小时数", "minutesPerRound": "每轮分钟数", "followingProbability": "关注概率", "recsysType": "推荐系统类型" } ``` ```json "api": { "...claus existents...", "requireFields": "未提供字段", "requireEntityUuid": "需要 source_entity_uuid", "requireProfilesReady": "模拟必须处于 profiles_ready 状态(当前:{status})", "requireAgentId": "需要 agent user_id", "agentNotFound": "未找到 user_id={user_id} 的智能体", "agentNoSourceEntity": "智能体没有 source_entity_uuid,无法重新生成", "cannotCreateAgentInStatus": "模拟状态为 {status} 时无法创建智能体", "regenerateRequiresProfilesReady": "重新生成需要 profiles_ready 状态(当前:{status})" } ``` - [ ] **Step 3: Commit** ```bash git add locales/en.json locales/zh.json git commit -m "feat(i18n): add F2-A+B translation keys for en and zh" ``` --- ## Task 14: Frontend — Fase A: modal d'edició i control de generació **Files:** - Modify: `frontend/src/components/Step2EnvSetup.vue` Aquesta és la tasca de frontend més gran. Cal refactoritzar el component existent per suportar les dues fases. El principi és: **canviar el mínim possible del flux existent** i afegir les pantalles de Fase A/B al damunt. - [ ] **Step 1: Afegir refs de fase i modal a la secció `