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 flask import request, jsonify, send_file
|
||||||
|
|
||||||
from . import simulation_bp
|
from . import simulation_bp
|
||||||
|
from .. import get_storage
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
from ..services.zep_entity_reader import ZepEntityReader
|
from ..services.zep_entity_reader import ZepEntityReader
|
||||||
from ..services.oasis_profile_generator import OasisProfileGenerator
|
from ..services.oasis_profile_generator import OasisProfileGenerator
|
||||||
|
|
@ -190,7 +191,7 @@ def create_simulation():
|
||||||
"error": t('api.projectNotFound', id=project_id)
|
"error": t('api.projectNotFound', id=project_id)
|
||||||
}), 404
|
}), 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:
|
if not graph_id:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": False,
|
"success": False,
|
||||||
|
|
@ -436,7 +437,7 @@ def prepare_simulation():
|
||||||
}), 404
|
}), 404
|
||||||
|
|
||||||
# Get simulation requirement
|
# Get simulation requirement
|
||||||
simulation_requirement = project.simulation_requirement or ""
|
simulation_requirement = project.get("simulation_requirement") or ""
|
||||||
if not simulation_requirement:
|
if not simulation_requirement:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": False,
|
"success": False,
|
||||||
|
|
@ -444,7 +445,7 @@ def prepare_simulation():
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Get document text
|
# 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')
|
entity_types_list = data.get('entity_types')
|
||||||
use_llm_for_profiles = data.get('use_llm_for_profiles', True)
|
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)
|
"error": t('api.taskNotFound', id=task_id)
|
||||||
}), 404
|
}), 404
|
||||||
|
|
||||||
task_dict = task.to_dict()
|
task_dict = task
|
||||||
task_dict["already_prepared"] = False
|
task_dict["already_prepared"] = False
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|
@ -932,10 +933,10 @@ def get_simulation_history():
|
||||||
|
|
||||||
# Get associated project's file list (up to 3)
|
# Get associated project's file list (up to 3)
|
||||||
project = ProjectManager.get_project(sim.project_id)
|
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"] = [
|
sim_dict["files"] = [
|
||||||
{"filename": f.get("filename", "Unknown file")}
|
{"filename": f.get("filename", "Unknown file")}
|
||||||
for f in project.files[:3]
|
for f in project.get("files", [])[:3]
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
sim_dict["files"] = []
|
sim_dict["files"] = []
|
||||||
|
|
@ -1573,7 +1574,7 @@ def start_simulation():
|
||||||
# Try to get from project
|
# Try to get from project
|
||||||
project = ProjectManager.get_project(state.project_id)
|
project = ProjectManager.get_project(state.project_id)
|
||||||
if project:
|
if project:
|
||||||
graph_id = project.graph_id
|
graph_id = project.get("graph_id")
|
||||||
|
|
||||||
if not graph_id:
|
if not graph_id:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|
@ -2699,3 +2700,29 @@ def close_simulation_env():
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
"traceback": traceback.format_exc()
|
"traceback": traceback.format_exc()
|
||||||
}), 500
|
}), 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}"
|
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