diff --git a/docs/superpowers/plans/2026-05-03-f2ab-agents-configurables.md b/docs/superpowers/plans/2026-05-03-f2ab-agents-configurables.md new file mode 100644 index 00000000..e3a187cc --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-f2ab-agents-configurables.md @@ -0,0 +1,2405 @@ +# 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ó `