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:
|
||||
logger.error(f"clone_simulation failed: {e}")
|
||||
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}")
|
||||
|
||||
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,
|
||||
entity: EntityNode,
|
||||
user_id: int,
|
||||
extra_instructions: Optional[str] = None,
|
||||
use_llm: bool = True
|
||||
) -> OasisAgentProfile:
|
||||
"""
|
||||
Generate an OASIS Agent Profile from a Zep entity.
|
||||
Internal: generate an OASIS Agent Profile for a single entity.
|
||||
|
||||
Args:
|
||||
entity: Zep entity node
|
||||
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
|
||||
|
||||
Returns:
|
||||
|
|
@ -975,7 +998,7 @@ Important:
|
|||
entity_type = entity.get_entity_type() or "Entity"
|
||||
|
||||
try:
|
||||
profile = self.generate_profile_from_entity(
|
||||
profile = self._generate_single_profile(
|
||||
entity=entity,
|
||||
user_id=idx,
|
||||
use_llm=use_llm
|
||||
|
|
|
|||
|
|
@ -168,3 +168,74 @@ def test_patch_config_updates_total_hours(client, sim_prepared):
|
|||
data = resp.get_json()
|
||||
assert data["success"] is True
|
||||
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