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:
Ubuntu 2026-05-03 21:37:19 +00:00
parent 8a0854ceba
commit f7dd353a31
3 changed files with 175 additions and 8 deletions

View File

@ -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

View File

@ -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

View File

@ -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