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