2406 lines
92 KiB
Markdown
2406 lines
92 KiB
Markdown
# 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 /<sim_id>/agent/<user_id>` 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('/<simulation_id>/agent/<int:user_id>', 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 /<sim_id>/agent/<user_id>` a `simulation.py`**
|
||
|
||
```python
|
||
@simulation_bp.route('/<simulation_id>/agent/<int:user_id>', 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 /<sim_id>/generate-config` a `simulation.py`**
|
||
|
||
```python
|
||
@simulation_bp.route('/<simulation_id>/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 /<sim_id>/config` a `simulation.py`**
|
||
|
||
```python
|
||
@simulation_bp.route('/<simulation_id>/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 /<sim_id>/clone` a `simulation.py`**
|
||
|
||
```python
|
||
@simulation_bp.route('/<simulation_id>/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 /<sim_id>/agent` a `simulation.py`**
|
||
|
||
```python
|
||
@simulation_bp.route('/<simulation_id>/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 /<sim_id>/agent/<user_id>/regenerate` a `simulation.py`**
|
||
|
||
```python
|
||
@simulation_bp.route('/<simulation_id>/agent/<int:user_id>/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ó `<script setup>`**
|
||
|
||
Localitza el bloc de refs de `Step2EnvSetup.vue` i afegeix:
|
||
|
||
```javascript
|
||
import { patchAgent, deleteAgent, regenerateAgent, generateConfig } from '../api/simulation'
|
||
|
||
// Fase A/B state
|
||
const currentPhase = ref('generating') // 'generating' | 'phase_a' | 'phase_b'
|
||
const agentModalOpen = ref(false)
|
||
const agentModalMode = ref('view') // 'view' | 'edit' | 'regen'
|
||
const selectedAgent = ref(null)
|
||
const editForm = ref({})
|
||
const regenInstructions = ref('')
|
||
const regenLoading = ref(false)
|
||
const editLoading = ref(false)
|
||
const deleteConfirmAgent = ref(null)
|
||
const generateConfigLoading = ref(false)
|
||
const generateConfigTaskId = ref(null)
|
||
```
|
||
|
||
- [ ] **Step 2: Detectar la transició a `phase_a` quan la generació es completa**
|
||
|
||
Localitza el codi que processa la fi de la generació (`status === 'ready'` o `config_generated === true`) i afegeix:
|
||
|
||
```javascript
|
||
// Quan la generació de perfils s'acaba (status = profiles_ready)
|
||
// canvia a la fase A en lloc d'anar directament a "prepared"
|
||
if (data.status === 'profiles_ready' || (data.status === 'preparing' && profilesGenerated)) {
|
||
currentPhase.value = 'phase_a'
|
||
}
|
||
```
|
||
|
||
Nota: cal ajustar el backend per retornar `profiles_ready` al final de la generació de perfils (abans de generar la config). Això implica modificar `SimulationManager.prepare_simulation()` per aturar-se a `profiles_ready` en lloc de continuar amb la config.
|
||
|
||
- [ ] **Step 3: Afegir funcions `openAgentModal`, `saveAgent`, `confirmDelete`, `doRegenerate`**
|
||
|
||
```javascript
|
||
const openAgentModal = (agent, mode = 'view') => {
|
||
selectedAgent.value = agent
|
||
agentModalMode.value = mode
|
||
editForm.value = { ...agent }
|
||
regenInstructions.value = ''
|
||
agentModalOpen.value = true
|
||
}
|
||
|
||
const closeAgentModal = () => {
|
||
agentModalOpen.value = false
|
||
selectedAgent.value = null
|
||
}
|
||
|
||
const saveAgent = async () => {
|
||
if (!selectedAgent.value || !simulationId.value) return
|
||
editLoading.value = true
|
||
try {
|
||
const res = await patchAgent(simulationId.value, selectedAgent.value.user_id, editForm.value)
|
||
if (res.data.success) {
|
||
// Update agent in local profiles list
|
||
const idx = profiles.value.findIndex(p => p.user_id === selectedAgent.value.user_id)
|
||
if (idx !== -1) profiles.value[idx] = res.data.data
|
||
closeAgentModal()
|
||
}
|
||
} finally {
|
||
editLoading.value = false
|
||
}
|
||
}
|
||
|
||
const confirmDeleteAgent = async (agent) => {
|
||
if (!confirm(t('step2.deleteAgentConfirm'))) return
|
||
const res = await deleteAgent(simulationId.value, agent.user_id)
|
||
if (res.data.success) {
|
||
profiles.value = profiles.value.filter(p => p.user_id !== agent.user_id)
|
||
}
|
||
}
|
||
|
||
const doRegenerate = async () => {
|
||
if (!selectedAgent.value) return
|
||
regenLoading.value = true
|
||
try {
|
||
const res = await regenerateAgent(simulationId.value, selectedAgent.value.user_id, {
|
||
extra_instructions: regenInstructions.value
|
||
})
|
||
if (res.data.success) {
|
||
// Poll task until complete, then refresh profiles
|
||
await pollTaskUntilDone(res.data.data.task_id, () => refreshProfiles())
|
||
closeAgentModal()
|
||
}
|
||
} finally {
|
||
regenLoading.value = false
|
||
}
|
||
}
|
||
|
||
const continueToPhaseB = async () => {
|
||
generateConfigLoading.value = true
|
||
try {
|
||
const res = await generateConfig(simulationId.value)
|
||
if (res.data.success) {
|
||
generateConfigTaskId.value = res.data.data.task_id
|
||
// Poll until task completes → then switch to phase_b
|
||
await pollTaskUntilDone(res.data.data.task_id, () => {
|
||
currentPhase.value = 'phase_b'
|
||
})
|
||
}
|
||
} finally {
|
||
generateConfigLoading.value = false
|
||
}
|
||
}
|
||
```
|
||
|
||
Helper de polling (si no existeix ja al component):
|
||
|
||
```javascript
|
||
const pollTaskUntilDone = async (taskId, onComplete, intervalMs = 2000) => {
|
||
const { getPrepareStatus } = await import('../api/simulation')
|
||
return new Promise((resolve) => {
|
||
const interval = setInterval(async () => {
|
||
try {
|
||
const res = await getPrepareStatus({ task_id: taskId })
|
||
const status = res.data?.data?.status
|
||
if (status === 'completed') {
|
||
clearInterval(interval)
|
||
onComplete && onComplete(res.data?.data?.result)
|
||
resolve()
|
||
} else if (status === 'failed') {
|
||
clearInterval(interval)
|
||
resolve()
|
||
}
|
||
} catch {
|
||
clearInterval(interval)
|
||
resolve()
|
||
}
|
||
}, intervalMs)
|
||
})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Afegir el botó "Continuar →" al template**
|
||
|
||
Localitza la secció on es mostren les cards d'agents (dins la condició de generació completa) i afegeix just a sota:
|
||
|
||
```vue
|
||
<!-- Fase A: botó de continuació -->
|
||
<div v-if="currentPhase === 'phase_a'" class="phase-a-footer">
|
||
<button
|
||
class="continue-btn"
|
||
:disabled="generateConfigLoading"
|
||
@click="continueToPhaseB"
|
||
>
|
||
<span v-if="generateConfigLoading">{{ $t('step2.generatingConfig') }}</span>
|
||
<span v-else>{{ $t('step2.continueToPhaseB') }}</span>
|
||
</button>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 5: Afegir el modal d'edició d'agent al template**
|
||
|
||
```vue
|
||
<!-- Agent edit/regen modal -->
|
||
<div v-if="agentModalOpen" class="agent-modal-overlay" @click.self="closeAgentModal">
|
||
<div class="agent-modal">
|
||
<div class="modal-header">
|
||
<span class="modal-title">{{ selectedAgent?.name }}</span>
|
||
<span v-if="selectedAgent?.manually_edited" class="edited-badge">{{ $t('step2.manuallyEditedBadge') }}</span>
|
||
<button class="modal-close" @click="closeAgentModal">✕</button>
|
||
</div>
|
||
|
||
<div v-if="agentModalMode === 'edit'" class="modal-body">
|
||
<div class="field-group" v-for="field in ['name', 'bio', 'persona', 'age', 'gender', 'mbti', 'country', 'profession', 'stance']" :key="field">
|
||
<label>{{ field }}</label>
|
||
<textarea v-if="['bio', 'persona'].includes(field)" v-model="editForm[field]" rows="3" />
|
||
<input v-else v-model="editForm[field]" />
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn-secondary" @click="closeAgentModal">{{ $t('common.cancel') }}</button>
|
||
<button class="btn-primary" :disabled="editLoading" @click="saveAgent">{{ $t('common.save') }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="agentModalMode === 'regen'" class="modal-body">
|
||
<p>{{ $t('step2.regenerateAgentHint') }}</p>
|
||
<textarea v-model="regenInstructions" rows="3" :placeholder="$t('step2.extraInstructions')" />
|
||
<div class="modal-actions">
|
||
<button class="btn-secondary" @click="closeAgentModal">{{ $t('common.cancel') }}</button>
|
||
<button class="btn-primary" :disabled="regenLoading" @click="doRegenerate">{{ $t('step2.regenerateAgent') }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="modal-body modal-view">
|
||
<p><strong>Bio:</strong> {{ selectedAgent?.bio }}</p>
|
||
<p><strong>Persona:</strong> {{ selectedAgent?.persona }}</p>
|
||
<div class="modal-actions">
|
||
<button class="btn-secondary" @click="agentModalMode = 'regen'">{{ $t('step2.regenerateAgent') }}</button>
|
||
<button class="btn-danger" @click="confirmDeleteAgent(selectedAgent); closeAgentModal()">{{ $t('step2.deleteAgent') }}</button>
|
||
<button class="btn-primary" @click="agentModalMode = 'edit'">{{ $t('step2.editAgent') }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 6: Afegir indicador `manually_edited` a les cards d'agent**
|
||
|
||
Localitza el template de cada card d'agent i afegeix:
|
||
|
||
```vue
|
||
<span v-if="agent.manually_edited" class="manually-edited-badge">{{ $t('step2.manuallyEditedBadge') }}</span>
|
||
```
|
||
|
||
I afegeix un botó d'acció a cada card:
|
||
|
||
```vue
|
||
<button class="agent-action-btn" @click="openAgentModal(agent)">···</button>
|
||
```
|
||
|
||
- [ ] **Step 7: Afegir CSS nou**
|
||
|
||
```css
|
||
.phase-a-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
padding: 16px 0;
|
||
border-top: 1px solid #EAEAEA;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.continue-btn {
|
||
padding: 10px 24px;
|
||
background: #000;
|
||
color: #FFF;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: 700;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.continue-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
|
||
.agent-modal-overlay {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
||
display: flex; align-items: center; justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.agent-modal {
|
||
background: #FFF; border-radius: 12px; padding: 24px;
|
||
width: 560px; max-height: 80vh; overflow-y: auto;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex; align-items: center; gap: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.modal-title { font-weight: 700; font-size: 18px; flex: 1; }
|
||
|
||
.edited-badge, .manually-edited-badge {
|
||
background: #FFF3CD; color: #856404;
|
||
padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;
|
||
}
|
||
|
||
.modal-close { background: none; border: none; cursor: pointer; font-size: 18px; }
|
||
|
||
.modal-body .field-group { margin-bottom: 12px; }
|
||
.modal-body label { display: block; font-size: 12px; font-weight: 600; color: #666; margin-bottom: 4px; text-transform: uppercase; }
|
||
.modal-body input, .modal-body textarea {
|
||
width: 100%; padding: 8px 12px; border: 1px solid #E0E0E0;
|
||
border-radius: 6px; font-size: 14px; resize: vertical;
|
||
}
|
||
|
||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
||
.btn-primary { padding: 8px 16px; background: #000; color: #FFF; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
|
||
.btn-secondary { padding: 8px 16px; background: #F5F5F5; color: #333; border: 1px solid #DDD; border-radius: 6px; cursor: pointer; font-weight: 600; }
|
||
.btn-danger { padding: 8px 16px; background: #FFF; color: #D32F2F; border: 1px solid #FFCDD2; border-radius: 6px; cursor: pointer; font-weight: 600; }
|
||
.btn-primary:disabled, .btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
|
||
.agent-action-btn { background: none; border: none; cursor: pointer; padding: 4px 8px; font-size: 18px; color: #666; }
|
||
.agent-action-btn:hover { color: #000; }
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/Step2EnvSetup.vue
|
||
git commit -m "feat(step2): add Fase A agent edit/delete/regen modal and continue button"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Frontend — Fase B: formulari de paràmetres i `enable_graph_memory_update` checkbox
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/components/Step2EnvSetup.vue`
|
||
- Modify: `frontend/src/components/Step3Simulation.vue`
|
||
|
||
- [ ] **Step 1: Afegir refs de Fase B**
|
||
|
||
```javascript
|
||
import { patchSimulationConfig, getSimulationConfig } from '../api/simulation'
|
||
|
||
const phaseBConfig = ref(null) // loaded from GET /config after phase_b transition
|
||
const phaseBLoading = ref(false)
|
||
const phaseBSaving = ref(false)
|
||
const configForm = ref({}) // global params form
|
||
```
|
||
|
||
- [ ] **Step 2: Carregar la config quan es transiciona a `phase_b`**
|
||
|
||
Modifica `continueToPhaseB` per carregar la config un cop el task és complet:
|
||
|
||
```javascript
|
||
const continueToPhaseB = async () => {
|
||
generateConfigLoading.value = true
|
||
try {
|
||
const res = await generateConfig(simulationId.value)
|
||
if (res.data.success) {
|
||
await pollTaskUntilDone(res.data.data.task_id, async () => {
|
||
// Load config for Fase B display
|
||
const configRes = await getSimulationConfig(simulationId.value)
|
||
if (configRes.data.success) {
|
||
phaseBConfig.value = configRes.data.data
|
||
const tc = phaseBConfig.value?.time_config || {}
|
||
configForm.value = {
|
||
total_simulation_hours: tc.total_simulation_hours ?? 24,
|
||
minutes_per_round: tc.minutes_per_round ?? 60,
|
||
following_probability: phaseBConfig.value?.following_probability ?? 0.05,
|
||
recsys_type: phaseBConfig.value?.recsys_type ?? 'random',
|
||
}
|
||
}
|
||
currentPhase.value = 'phase_b'
|
||
})
|
||
}
|
||
} finally {
|
||
generateConfigLoading.value = false
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Afegir `saveGlobalConfig` i `launchSimulation`**
|
||
|
||
```javascript
|
||
const saveGlobalConfig = async () => {
|
||
phaseBSaving.value = true
|
||
try {
|
||
await patchSimulationConfig(simulationId.value, configForm.value)
|
||
} finally {
|
||
phaseBSaving.value = false
|
||
}
|
||
}
|
||
|
||
const launchSimulation = async () => {
|
||
// Save config before launching
|
||
await saveGlobalConfig()
|
||
// Navigate to Step 3 (same as existing flow)
|
||
emit('go-step3', simulationId.value)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Afegir la secció de Fase B al template**
|
||
|
||
Just sota la secció de Fase A (o condicionalment quan `currentPhase === 'phase_b'`):
|
||
|
||
```vue
|
||
<!-- Fase B: Global parameters form -->
|
||
<div v-if="currentPhase === 'phase_b'" class="phase-b-section">
|
||
<h3>{{ $t('step2.phaseBTitle') }}</h3>
|
||
<p class="section-subtitle">{{ $t('step2.phaseBSubtitle') }}</p>
|
||
|
||
<div class="config-form">
|
||
<div class="config-field">
|
||
<label>{{ $t('step2.totalHours') }}</label>
|
||
<input type="number" v-model.number="configForm.total_simulation_hours" min="1" max="720" />
|
||
</div>
|
||
<div class="config-field">
|
||
<label>{{ $t('step2.minutesPerRound') }}</label>
|
||
<input type="number" v-model.number="configForm.minutes_per_round" min="1" max="1440" />
|
||
</div>
|
||
<div class="config-field">
|
||
<label>{{ $t('step2.followingProbability') }}</label>
|
||
<input type="number" v-model.number="configForm.following_probability" min="0" max="1" step="0.01" />
|
||
</div>
|
||
<div class="config-field">
|
||
<label>{{ $t('step2.recsysType') }}</label>
|
||
<select v-model="configForm.recsys_type">
|
||
<option value="random">Random</option>
|
||
<option value="interest">Interest-based</option>
|
||
<option value="twhin">TWHIN</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="phase-b-footer">
|
||
<button class="btn-secondary" @click="currentPhase = 'phase_a'">← Back</button>
|
||
<button class="continue-btn" :disabled="phaseBSaving" @click="launchSimulation">
|
||
{{ $t('step2.launchSimulation') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 5: Convertir `enable_graph_memory_update` a checkbox a `Step3Simulation.vue`**
|
||
|
||
Localitza la línia `enable_graph_memory_update: true` (hardcoded) a `Step3Simulation.vue:402` i substitueix per un ref reactiu:
|
||
|
||
```javascript
|
||
const enableGraphMemoryUpdate = ref(true) // afegir als refs existents
|
||
```
|
||
|
||
Localitza la crida a `startSimulation` i canvia el paràmetre:
|
||
|
||
```javascript
|
||
enable_graph_memory_update: enableGraphMemoryUpdate.value // era: true
|
||
```
|
||
|
||
Afegeix el checkbox al template, a la secció de configuració del Step 3:
|
||
|
||
```vue
|
||
<div class="option-row">
|
||
<label class="toggle-label">
|
||
<input type="checkbox" v-model="enableGraphMemoryUpdate" />
|
||
<span>Graph Memory Update</span>
|
||
<span class="hint">Update agent conversations to graph in real time (needed for report analysis)</span>
|
||
</label>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 6: Afegir CSS per Fase B**
|
||
|
||
```css
|
||
.phase-b-section { padding: 16px 0; }
|
||
.config-form { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0; }
|
||
.config-field label { display: block; font-size: 12px; font-weight: 600; color: #666; margin-bottom: 4px; text-transform: uppercase; }
|
||
.config-field input, .config-field select { width: 100%; padding: 8px 12px; border: 1px solid #E0E0E0; border-radius: 6px; font-size: 14px; }
|
||
.phase-b-footer { display: flex; justify-content: space-between; padding-top: 16px; border-top: 1px solid #EAEAEA; }
|
||
|
||
.option-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
|
||
.toggle-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
|
||
.toggle-label .hint { color: #999; font-size: 12px; }
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/Step2EnvSetup.vue frontend/src/components/Step3Simulation.vue
|
||
git commit -m "feat(step2): add Fase B config form and launch button; Step3 enable_graph_memory_update checkbox"
|
||
```
|
||
|
||
---
|
||
|
||
## Verificació end-to-end
|
||
|
||
Seguir els tests manuals de la spec:
|
||
|
||
- [ ] **Test 1 (flux complet)**: Generar agents → editar bio → "Continuar →" → verificar config → editar `total_simulation_hours` → llançar
|
||
- [ ] **Test 2 (protecció de sobreescriptura)**: Editar agent durant generació → verificar que no es sobreescriu
|
||
- [ ] **Test 3 (regeneració individual)**: `profiles_ready` → regenerar amb instrucció → spinner → perfil canviat
|
||
- [ ] **Test 4 (crear i eliminar)**: Afegir agent nou → verificar a cards → eliminar → verificar que desapareix
|
||
- [ ] **Test 5 (selector N)**: 50 entitats → reduir a 30 → verificar 30 agents generats
|
||
- [ ] **Test 6 (clonació)**: Completar sim1 → clonar al mateix projecte → editar un agent → llançar sim2 → sim1 intacta
|
||
- [ ] **Test 7 (aïllament grafs)**: Sim1 + sim2 amb `enable_graph_memory_update=true` → reports aïllats; esborrar sim2 → `graph_id_simulation` esborrat de Neo4j
|
||
|
||
---
|
||
|
||
## Notes d'implementació
|
||
|
||
1. **Ordre de tasques**: Les Tasks 1-9 (backend) i 12-13 (API/i18n) es poden fer en un bloc; les Tasks 14-15 (frontend Vue) en un segon bloc.
|
||
|
||
2. **`prepare_simulation` i `profiles_ready`**: El canvi més crític és que `SimulationManager.prepare_simulation()` ara ha d'aturar-se a `profiles_ready` en lloc de continuar amb `generate_simulation_config`. La config es generarà a `generate-config` quan l'usuari premi "Continuar →". Si `max_agents` no es toca, el comportament és idèntic a l'actual (totes les entitats, generació completa automàtica).
|
||
|
||
3. **Compatibilitat enrere**: Les simulacions existents amb `status: ready` funcionen exactament igual: l'endpoint `POST /start` les accepta sense canvis.
|
||
|
||
4. **Subsistema 5 (clone_graph)**: Requereix Graphiti/Neo4j actiu. Si el backend és Zep, la crida a `clone_graph` no existirà al backend i la simulació usarà el `graph_id` compartit (comportament actual). El `hasattr(graph_backend, 'clone_graph')` al Task 11 n'és el guard.
|