feat(simulation): add POST /agent create and /agent/{id}/regenerate endpoints
Implements Tasks 8 and 9 of F2-A+B: async agent creation from entity UUID and async per-agent profile regeneration, both with atomic profile file writes. Refactors generate_profile_from_entity to accept extra_instructions instead of requiring user_id (renamed internal logic to _generate_single_profile). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
306414a555
commit
5d679024c3
|
|
@ -2912,3 +2912,232 @@ def clone_simulation(simulation_id: str):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"clone_simulation failed: {e}")
|
logger.error(f"clone_simulation failed: {e}")
|
||||||
return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500
|
return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@simulation_bp.route('/<simulation_id>/agent', methods=['POST'])
|
||||||
|
def create_agent(simulation_id: str):
|
||||||
|
"""
|
||||||
|
Task 8 — Create a new agent from an entity UUID.
|
||||||
|
|
||||||
|
Requires status in {profiles_ready, created}.
|
||||||
|
Runs async; returns task_id for polling.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from ..models.task import TaskManager, TaskStatus
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.get_json() or {}
|
||||||
|
source_entity_uuid = data.get("source_entity_uuid")
|
||||||
|
if not source_entity_uuid:
|
||||||
|
return jsonify({"success": False, "error": t('api.requireFields')}), 400
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
allowed_statuses = {SimulationStatus.PROFILES_READY, SimulationStatus.CREATED}
|
||||||
|
if state.status not in allowed_statuses:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": t('api.requireProfilesReady', 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=t('progress.generatingProfile'))
|
||||||
|
|
||||||
|
sim_dir = manager._get_simulation_dir(simulation_id)
|
||||||
|
profiles_file = os.path.join(sim_dir, "reddit_profiles.json")
|
||||||
|
|
||||||
|
# Fetch entity
|
||||||
|
reader = ZepEntityReader()
|
||||||
|
entity = reader.get_entity_with_context(state.graph_id, source_entity_uuid)
|
||||||
|
if not entity:
|
||||||
|
raise ValueError(f"Entity not found: {source_entity_uuid}")
|
||||||
|
|
||||||
|
# Read current profiles
|
||||||
|
with open(profiles_file, 'r', encoding='utf-8') as f:
|
||||||
|
profiles = json.load(f)
|
||||||
|
|
||||||
|
# Check for duplicate source_entity_uuid
|
||||||
|
for p in profiles:
|
||||||
|
if p.get("source_entity_uuid") == source_entity_uuid:
|
||||||
|
raise ValueError(f"Agent with source_entity_uuid '{source_entity_uuid}' already exists")
|
||||||
|
|
||||||
|
# Compute next user_id
|
||||||
|
next_user_id = max((p.get("user_id", -1) for p in profiles), default=-1) + 1
|
||||||
|
|
||||||
|
# Generate profile
|
||||||
|
gen = OasisProfileGenerator(graph_id=state.graph_id)
|
||||||
|
profile = gen.generate_profile_from_entity(entity, extra_instructions=extra_instructions)
|
||||||
|
profile.user_id = next_user_id
|
||||||
|
|
||||||
|
# Convert to dict and append
|
||||||
|
profile_dict = profile.to_reddit_format()
|
||||||
|
profile_dict["user_id"] = next_user_id
|
||||||
|
profile_dict["source_entity_uuid"] = profile.source_entity_uuid
|
||||||
|
profile_dict["source_entity_type"] = profile.source_entity_type
|
||||||
|
profile_dict["manually_edited"] = False
|
||||||
|
|
||||||
|
profiles.append(profile_dict)
|
||||||
|
|
||||||
|
# Atomic write: backup → write → delete backup
|
||||||
|
backup_file = profiles_file + ".bak"
|
||||||
|
if os.path.exists(profiles_file):
|
||||||
|
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)
|
||||||
|
if os.path.exists(backup_file):
|
||||||
|
os.remove(backup_file)
|
||||||
|
except Exception as write_err:
|
||||||
|
# Restore from backup on failure
|
||||||
|
if os.path.exists(backup_file):
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(backup_file, profiles_file)
|
||||||
|
raise write_err
|
||||||
|
|
||||||
|
# Update profiles_count in state
|
||||||
|
state2 = manager.get_simulation(simulation_id)
|
||||||
|
if state2:
|
||||||
|
state2.profiles_count = len(profiles)
|
||||||
|
manager._save_simulation_state(state2)
|
||||||
|
|
||||||
|
task_manager.complete_task(task_id, result={"user_id": next_user_id})
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@simulation_bp.route('/<simulation_id>/agent/<int:user_id>/regenerate', methods=['POST'])
|
||||||
|
def regenerate_agent(simulation_id: str, user_id: int):
|
||||||
|
"""
|
||||||
|
Task 9 — Regenerate an agent's profile from its source entity.
|
||||||
|
|
||||||
|
Requires status == profiles_ready and the agent must have source_entity_uuid.
|
||||||
|
Runs async; returns task_id for polling.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from ..models.task import TaskManager, TaskStatus
|
||||||
|
|
||||||
|
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.requireProfilesReady', status=state.status.value)
|
||||||
|
}), 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_agent():
|
||||||
|
set_locale(current_locale)
|
||||||
|
try:
|
||||||
|
task_manager.update_task(task_id, status=TaskStatus.PROCESSING, progress=0,
|
||||||
|
message=t('progress.generatingProfile'))
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Find the agent
|
||||||
|
agent_idx = None
|
||||||
|
agent = None
|
||||||
|
for i, p in enumerate(profiles):
|
||||||
|
if p.get("user_id") == user_id:
|
||||||
|
agent_idx = i
|
||||||
|
agent = p
|
||||||
|
break
|
||||||
|
|
||||||
|
if agent_idx is None:
|
||||||
|
raise LookupError(f"Agent with user_id {user_id} not found")
|
||||||
|
|
||||||
|
source_entity_uuid = agent.get("source_entity_uuid")
|
||||||
|
if not source_entity_uuid:
|
||||||
|
raise ValueError(f"Agent {user_id} has no source_entity_uuid — cannot regenerate")
|
||||||
|
|
||||||
|
# Fetch entity
|
||||||
|
reader = ZepEntityReader()
|
||||||
|
entity = reader.get_entity_with_context(state.graph_id, source_entity_uuid)
|
||||||
|
if not entity:
|
||||||
|
raise ValueError(f"Entity not found: {source_entity_uuid}")
|
||||||
|
|
||||||
|
# Generate new profile
|
||||||
|
gen = OasisProfileGenerator(graph_id=state.graph_id)
|
||||||
|
new_profile = gen.generate_profile_from_entity(entity, extra_instructions=extra_instructions)
|
||||||
|
new_profile.user_id = user_id
|
||||||
|
|
||||||
|
# Build dict, preserving user_id and resetting manually_edited
|
||||||
|
new_profile_dict = new_profile.to_reddit_format()
|
||||||
|
new_profile_dict["user_id"] = user_id
|
||||||
|
new_profile_dict["source_entity_uuid"] = new_profile.source_entity_uuid
|
||||||
|
new_profile_dict["source_entity_type"] = new_profile.source_entity_type
|
||||||
|
new_profile_dict["manually_edited"] = False
|
||||||
|
|
||||||
|
profiles[agent_idx] = new_profile_dict
|
||||||
|
|
||||||
|
# Atomic write
|
||||||
|
backup_file = profiles_file + ".bak"
|
||||||
|
if os.path.exists(profiles_file):
|
||||||
|
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)
|
||||||
|
if os.path.exists(backup_file):
|
||||||
|
os.remove(backup_file)
|
||||||
|
except Exception as write_err:
|
||||||
|
if os.path.exists(backup_file):
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(backup_file, profiles_file)
|
||||||
|
raise write_err
|
||||||
|
|
||||||
|
task_manager.complete_task(task_id, result={"user_id": user_id})
|
||||||
|
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_agent, 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
|
||||||
|
|
|
||||||
|
|
@ -231,17 +231,40 @@ class OasisProfileGenerator:
|
||||||
logger.warning(f"Zep client initialisation failed: {e}")
|
logger.warning(f"Zep client initialisation failed: {e}")
|
||||||
|
|
||||||
def generate_profile_from_entity(
|
def generate_profile_from_entity(
|
||||||
|
self,
|
||||||
|
entity: EntityNode,
|
||||||
|
extra_instructions: Optional[str] = None,
|
||||||
|
) -> OasisAgentProfile:
|
||||||
|
"""
|
||||||
|
Generate an OASIS Agent Profile from a Zep entity (public API).
|
||||||
|
|
||||||
|
The returned profile has user_id=0 as a placeholder; callers must
|
||||||
|
reassign user_id after insertion into the profiles list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity: Zep entity node
|
||||||
|
extra_instructions: Optional extra instructions for the LLM persona
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OasisAgentProfile
|
||||||
|
"""
|
||||||
|
return self._generate_single_profile(entity, user_id=0,
|
||||||
|
extra_instructions=extra_instructions)
|
||||||
|
|
||||||
|
def _generate_single_profile(
|
||||||
self,
|
self,
|
||||||
entity: EntityNode,
|
entity: EntityNode,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
extra_instructions: Optional[str] = None,
|
||||||
use_llm: bool = True
|
use_llm: bool = True
|
||||||
) -> OasisAgentProfile:
|
) -> OasisAgentProfile:
|
||||||
"""
|
"""
|
||||||
Generate an OASIS Agent Profile from a Zep entity.
|
Internal: generate an OASIS Agent Profile for a single entity.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entity: Zep entity node
|
entity: Zep entity node
|
||||||
user_id: User ID (for OASIS)
|
user_id: User ID (for OASIS)
|
||||||
|
extra_instructions: Optional extra instructions injected into the LLM prompt
|
||||||
use_llm: Whether to use an LLM to generate a detailed persona
|
use_llm: Whether to use an LLM to generate a detailed persona
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -975,7 +998,7 @@ Important:
|
||||||
entity_type = entity.get_entity_type() or "Entity"
|
entity_type = entity.get_entity_type() or "Entity"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
profile = self.generate_profile_from_entity(
|
profile = self._generate_single_profile(
|
||||||
entity=entity,
|
entity=entity,
|
||||||
user_id=idx,
|
user_id=idx,
|
||||||
use_llm=use_llm
|
use_llm=use_llm
|
||||||
|
|
|
||||||
|
|
@ -168,3 +168,74 @@ def test_patch_config_updates_total_hours(client, sim_prepared):
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
assert data["data"]["time_config"]["total_simulation_hours"] == 48
|
assert data["data"]["time_config"]["total_simulation_hours"] == 48
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_agent_adds_to_profiles(client, sim_with_profiles, monkeypatch):
|
||||||
|
sim_id = sim_with_profiles
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add source_entity_uuid to the first profile in the fixture's sim directory
|
||||||
|
import json as _j
|
||||||
|
from pathlib import Path
|
||||||
|
from backend.app.services.simulation_manager import SimulationManager
|
||||||
|
sim_dir = Path(SimulationManager.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"]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue