soci2 / src /soci /api /routes.py
RayMelius's picture
NN training: realistic persona data, graphs, hot-reload endpoint
b596ed6
"""REST API routes β€” city state, agents, history."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel
from typing import Optional
router = APIRouter()
class PlayerActionRequest(BaseModel):
action: str # move, talk, work, eat, etc.
target: str = ""
detail: str = ""
class PlayerJoinRequest(BaseModel):
name: str
background: str = "A newcomer to Soci City."
# ── Auth models ───────────────────────────────────────────────────────────────
class AuthRequest(BaseModel):
username: str
password: str
class PlayerCreateRequest(BaseModel):
token: str
name: str
age: int = 30
occupation: str = "Newcomer"
background: str = "A newcomer to Soci City."
gender: str = "unknown"
extraversion: int = 5
agreeableness: int = 7
openness: int = 6
class PlayerUpdateRequest(BaseModel):
token: str
name: Optional[str] = None
age: Optional[int] = None
occupation: Optional[str] = None
background: Optional[str] = None
gender: Optional[str] = None
extraversion: Optional[int] = None
agreeableness: Optional[int] = None
openness: Optional[int] = None
class PlayerTalkRequest(BaseModel):
token: str
target_id: str
message: str
class PlayerMoveRequest(BaseModel):
token: str
location: str
class PlayerPlanRequest(BaseModel):
token: str
plan_item: str
# ── Helper ────────────────────────────────────────────────────────────────────
def _find_player_apartment(sim) -> str:
"""Find an available residential location for a new player."""
preferred = [
"apartment_block_1", "apartment_block_2", "apartment_block_3",
"apt_northeast", "apt_northwest", "apt_southeast", "apt_southwest",
]
for loc_id in preferred:
loc = sim.city.get_location(loc_id)
if loc and not loc.is_full:
return loc_id
for loc in sim.city.get_locations_in_zone("residential"):
if not loc.is_full:
return loc.id
return "town_square"
async def _get_player_from_token(token: str):
"""Validate token and return (user, agent). Raises 401/404 on failure."""
from soci.api.server import get_simulation, get_database
db = get_database()
sim = get_simulation()
user = await db.get_user_by_token(token)
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired session token")
agent_id = user.get("agent_id")
agent = sim.agents.get(agent_id) if agent_id else None
return user, agent
@router.get("/city")
async def get_city():
"""Get the full city state β€” locations, agents, time, weather."""
from soci.api.server import get_simulation
sim = get_simulation()
return sim.get_state_summary()
@router.get("/city/locations")
async def get_locations():
"""Get all city locations and who's there."""
from soci.api.server import get_simulation
sim = get_simulation()
return {
lid: {
"name": loc.name,
"zone": loc.zone,
"description": loc.description,
"occupants": [
{"id": aid, "name": sim.agents[aid].name, "state": sim.agents[aid].state.value}
for aid in loc.occupants if aid in sim.agents
],
"connected_to": loc.connected_to,
}
for lid, loc in sim.city.locations.items()
}
@router.get("/agents")
async def get_agents():
"""Get summary of all agents."""
from soci.api.server import get_simulation
sim = get_simulation()
return {
aid: {
"name": a.name,
"age": a.persona.age,
"gender": a.persona.gender,
"occupation": a.persona.occupation,
"location": a.location,
"state": a.state.value,
"mood": round(a.mood, 2),
"action": a.current_action.detail if a.current_action else "idle",
"partner_id": a.partner_id,
"is_player": a.is_player,
}
for aid, a in sim.agents.items()
}
@router.get("/agents/{agent_id}")
async def get_agent(agent_id: str):
"""Get detailed info about a specific agent."""
from soci.api.server import get_simulation
sim = get_simulation()
agent = sim.agents.get(agent_id)
if not agent:
raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
loc = sim.city.get_location(agent.location)
p = agent.persona
return {
"id": agent.id,
"name": agent.name,
"age": p.age,
"gender": p.gender,
"occupation": p.occupation,
"background": p.background,
"traits": p.trait_summary,
"personality": {
"openness": getattr(p, "openness", 5),
"conscientiousness": getattr(p, "conscientiousness", 5),
"extraversion": getattr(p, "extraversion", 5),
"agreeableness": getattr(p, "agreeableness", 5),
"neuroticism": getattr(p, "neuroticism", 5),
},
"home_location": getattr(p, "home_location", ""),
"work_location": getattr(p, "work_location", ""),
"location": {"id": agent.location, "name": loc.name if loc else "unknown"},
"state": agent.state.value,
"mood": round(agent.mood, 2),
"needs": agent.needs.to_dict(),
"needs_description": agent.needs.describe(),
"action": agent.current_action.detail if agent.current_action else "idle",
"daily_plan": agent.daily_plan,
"partner_id": agent.partner_id,
"relationships": [
{
"agent_id": rel.agent_id,
"name": rel.agent_name,
"closeness": round(rel.closeness, 2),
"romantic_interest": round(rel.romantic_interest, 2),
"relationship_status": rel.relationship_status,
"trust": round(rel.trust, 2),
"sentiment": round(rel.sentiment, 2),
"familiarity": round(rel.familiarity, 2),
"interaction_count": rel.interaction_count,
"description": rel.describe(),
}
for rel in agent.relationships.get_closest(10)
],
"recent_memories": [
{
"time": f"Day {m.day} {m.time_str}",
"type": m.type.value,
"content": m.content,
"importance": m.importance,
}
for m in agent.memory.get_recent(10)
],
"life_events": agent.life_events[-20:],
"goals": agent.goals,
"pregnant": agent.pregnant,
"children": agent.children,
}
@router.get("/agents/{agent_id}/memories")
async def get_agent_memories(agent_id: str, limit: int = 20):
"""Get an agent's memory stream."""
from soci.api.server import get_simulation
sim = get_simulation()
agent = sim.agents.get(agent_id)
if not agent:
raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
return [
{
"id": m.id,
"time": f"Day {m.day} {m.time_str}",
"type": m.type.value,
"content": m.content,
"importance": m.importance,
"involved_agents": m.involved_agents,
}
for m in agent.memory.memories[-limit:]
]
@router.get("/conversations")
async def get_conversations(include_history: bool = True, limit: int = 20):
"""Get active and recent conversations with full dialogue."""
from soci.api.server import get_simulation
sim = get_simulation()
def format_conv(conv_data, active=False):
"""Format a conversation (dict or Conversation object)."""
if hasattr(conv_data, 'to_dict'):
d = conv_data.to_dict()
else:
d = conv_data
participant_names = [
sim.agents[p].name for p in d.get("participants", []) if p in sim.agents
]
return {
"id": d.get("id", ""),
"participants": d.get("participants", []),
"participant_names": participant_names,
"topic": d.get("topic", ""),
"location": d.get("location", ""),
"turns": d.get("turns", []),
"is_active": active,
}
result = {
"active": [
format_conv(c, active=True)
for c in sim.active_conversations.values()
],
"recent": [],
}
if include_history:
result["recent"] = [
format_conv(c) for c in sim.conversation_history[-limit:]
][::-1] # Most recent first
return result
class SwitchProviderRequest(BaseModel):
provider: str
model: Optional[str] = None
@router.get("/llm/providers")
async def get_llm_providers():
"""Return available LLM providers (those with API keys set) and the current one."""
import os
from soci.api.server import get_llm_provider, get_simulation
current = get_llm_provider()
current_model = getattr(get_simulation().llm, "default_model", "")
providers = []
# NN is always available β€” local ONNX model, no API key needed
providers.append({"id": "nn", "label": "Soci Agent NN", "icon": "🧠", "model": ""})
if os.environ.get("ANTHROPIC_API_KEY"):
providers.append({"id": "claude", "label": "Claude Haiku", "icon": "β—†", "model": ""})
if os.environ.get("GROQ_API_KEY"):
providers.append({"id": "groq", "label": "Groq Llama 8B", "icon": "⚑", "model": ""})
if os.environ.get("GEMINI_API_KEY"):
providers.append({"id": "gemini", "label": "Gemini 2.0 Flash Lite", "icon": "✦", "model": ""})
providers.append({"id": "ollama", "label": "Ollama (local LLM)", "icon": "πŸ¦™", "model": ""})
return {"current": current, "current_model": current_model, "providers": providers}
@router.get("/llm/test")
async def test_llm():
"""Make a minimal LLM call and return the raw response β€” for diagnosing provider issues."""
from soci.api.server import get_simulation
sim = get_simulation()
try:
raw = await sim.llm.complete(
system="You are a test assistant.",
user_message='Reply with exactly: {"ok": true}',
max_tokens=32,
)
error_detail = getattr(sim.llm, "_auth_error", "") or getattr(sim.llm, "_last_error", "")
return {"ok": bool(raw), "raw": raw,
"provider": getattr(sim.llm, "provider", "?"),
"model": getattr(sim.llm, "default_model", "?"),
"error": error_detail}
except Exception as e:
return {"ok": False, "raw": "", "error": str(e)}
@router.post("/llm/provider")
async def set_llm_provider(req: SwitchProviderRequest):
"""Hot-swap the active LLM provider."""
from soci.api.server import switch_llm_provider
valid = {"claude", "groq", "gemini", "nn", "ollama"}
if req.provider not in valid:
raise HTTPException(status_code=400, detail=f"Unknown provider '{req.provider}'")
try:
await switch_llm_provider(req.provider, model=req.model or None)
return {"ok": True, "provider": req.provider, "model": req.model}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/nn/reload")
async def reload_nn_model():
"""Hot-reload the NN model from HuggingFace Hub without restarting."""
from soci.api.server import get_simulation, get_llm_provider
sim = get_simulation()
# If current provider is NN, reload directly
if get_llm_provider() == "nn":
from soci.engine.nn_client import NNClient
if isinstance(sim.llm, NNClient):
msg = sim.llm.reload()
return {"ok": True, "message": msg}
# NN not active β€” try to reload anyway if there's an NN client we can find
# or just re-download the model file for next time NN is activated
try:
from soci.engine.nn_client import _download_model, _MODEL_FILENAME
from pathlib import Path
local = Path("models") / _MODEL_FILENAME
if local.exists():
local.unlink()
path = _download_model()
size = Path(path).stat().st_size
return {
"ok": True,
"message": f"NN model re-downloaded ({size / 1024:.0f} KB). "
f"Switch to NN provider to use it.",
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to reload NN model: {e}")
@router.get("/llm/quota")
async def get_llm_quota():
"""Return remaining daily quota and usage stats for budget planning.
Supports Gemini (tracked internally) and Groq (estimated from RPM Γ— hours remaining).
"""
import os
from soci.api.server import get_simulation, get_llm_provider, _sim_speed
sim = get_simulation()
llm = sim.llm
provider = get_llm_provider()
# Per-provider quota info
# Gemini: has internal daily tracking (_daily_limit, _daily_requests)
# Groq: 30 RPM free tier, ~14,400 RPD; no internal daily counter
daily_limit = getattr(llm, "_daily_limit", 0)
daily_requests = getattr(llm, "_daily_requests", 0)
# Build quota for all rate-limited providers (even if not currently active)
providers_quota = {}
# Gemini quota
if os.environ.get("GEMINI_API_KEY"):
g_limit = daily_limit if provider == "gemini" else 1500
g_used = daily_requests if provider == "gemini" else 0
providers_quota["gemini"] = {
"daily_limit": g_limit,
"daily_requests": g_used,
"remaining": max(0, g_limit - g_used),
}
# Groq quota (estimated: 30 RPM Γ— 60 min Γ— 24h = 14,400 RPD free tier)
if os.environ.get("GROQ_API_KEY"):
groq_limit = int(os.environ.get("GROQ_DAILY_LIMIT", "14400"))
groq_used = getattr(llm, "_daily_requests", 0) if provider == "groq" else 0
providers_quota["groq"] = {
"daily_limit": groq_limit,
"daily_requests": groq_used,
"remaining": max(0, groq_limit - groq_used),
}
# Current provider's quota (for backward compat with nn_selfimprove)
cur = providers_quota.get(provider, {"daily_limit": 0, "daily_requests": 0, "remaining": 0})
# Estimate ticks per hour β€” rate-limited providers use 4s tick delay
tick_delay_current = 4.0 if provider in ("gemini", "groq") else 2.0
ticks_per_hour = 3600.0 / (tick_delay_current * max(_sim_speed, 0.01))
max_calls_per_tick = 2 if provider in ("gemini", "groq") else 5
num_agents = len(sim.agents)
# Per-provider rate info (RPM is the real bottleneck, not probability)
# Gemini: 4 RPM hard limit β†’ max 240 calls/hour
# Groq: 28 RPM hard limit β†’ max 1680 calls/hour
provider_rpm = {"gemini": 4, "groq": 28}
for pid in providers_quota:
rpm = provider_rpm.get(pid, 30)
providers_quota[pid]["rpm"] = rpm
providers_quota[pid]["max_calls_per_hour"] = rpm * 60
# Expose rate-limit status (detects actual exhaustion from 429 errors)
llm_status = getattr(llm, "llm_status", "active")
if provider in providers_quota:
providers_quota[provider]["status"] = llm_status
if llm_status == "limited":
# Override remaining to 0 β€” the API is actually returning 429s
providers_quota[provider]["remaining"] = 0
return {
"provider": provider,
"daily_limit": cur["daily_limit"],
"daily_requests": cur["daily_requests"],
"remaining": cur["remaining"],
"providers": providers_quota,
"ticks_per_hour": round(ticks_per_hour, 1),
"max_calls_per_tick": max_calls_per_tick,
"num_agents": num_agents,
"sim_speed": _sim_speed,
}
@router.get("/stats")
async def get_stats():
"""Get simulation statistics and LLM usage."""
from soci.api.server import get_simulation
sim = get_simulation()
return {
"clock": sim.clock.to_dict(),
"total_agents": len(sim.agents),
"active_conversations": len(sim.active_conversations),
"llm_usage": {
"total_calls": sim.llm.usage.total_calls,
"total_input_tokens": sim.llm.usage.total_input_tokens,
"total_output_tokens": sim.llm.usage.total_output_tokens,
"estimated_cost_usd": round(sim.llm.usage.estimated_cost_usd, 4),
"calls_by_model": sim.llm.usage.calls_by_model,
},
}
# ── Auth endpoints ────────────────────────────────────────────────────────────
@router.post("/auth/register")
async def auth_register(request: AuthRequest):
"""Register a new user and auto-create their player agent."""
from soci.api.server import get_simulation, get_database
from soci.agents.agent import Agent
from soci.agents.persona import Persona
db = get_database()
sim = get_simulation()
if not request.username.strip():
raise HTTPException(status_code=400, detail="Username cannot be empty")
if len(request.password) < 3:
raise HTTPException(status_code=400, detail="Password must be at least 3 characters")
try:
user = await db.create_user(request.username.strip(), request.password)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
# Auto-create default player agent with an apartment
safe_name = request.username.strip()
player_id = f"player_{safe_name.lower().replace(' ', '_')}"
# Ensure unique ID
suffix = 1
base_id = player_id
while player_id in sim.agents:
player_id = f"{base_id}_{suffix}"
suffix += 1
home = _find_player_apartment(sim)
persona = Persona(
id=player_id,
name=safe_name,
age=30,
occupation="Newcomer",
gender="unknown",
background="A newcomer to Soci City.",
home_location=home,
work_location="",
extraversion=5,
agreeableness=7,
openness=6,
)
agent = Agent(persona)
agent.is_player = True
sim.add_agent(agent)
await db.set_user_agent(safe_name, player_id)
user["agent_id"] = player_id
return user
@router.post("/auth/login")
async def auth_login(request: AuthRequest):
"""Login and return session token."""
from soci.api.server import get_database
db = get_database()
user = await db.authenticate_user(request.username.strip(), request.password)
if not user:
raise HTTPException(status_code=401, detail="Invalid username or password")
return user
@router.get("/auth/me")
async def auth_me(authorization: str = Header(default="")):
"""Verify session token and return current user info."""
token = authorization.removeprefix("Bearer ").strip()
if not token:
raise HTTPException(status_code=401, detail="No token provided")
from soci.api.server import get_database
db = get_database()
user = await db.get_user_by_token(token)
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return user
@router.post("/auth/logout")
async def auth_logout(authorization: str = Header(default="")):
"""Invalidate session token."""
token = authorization.removeprefix("Bearer ").strip()
from soci.api.server import get_database
db = get_database()
await db.logout_user(token)
return {"ok": True}
# ── Player management endpoints ───────────────────────────────────────────────
@router.post("/player/create")
async def player_create(request: PlayerCreateRequest):
"""Create or replace the player's agent."""
from soci.api.server import get_simulation, get_database
from soci.agents.agent import Agent
from soci.agents.persona import Persona
db = get_database()
sim = get_simulation()
user = await db.get_user_by_token(request.token)
if not user:
raise HTTPException(status_code=401, detail="Invalid token")
player_id = f"player_{request.name.lower().replace(' ', '_')}"
suffix = 1
base_id = player_id
while player_id in sim.agents and sim.agents[player_id].persona.name != request.name:
player_id = f"{base_id}_{suffix}"
suffix += 1
# Remove old agent if exists
old_id = user.get("agent_id")
if old_id and old_id in sim.agents:
loc = sim.city.get_location(sim.agents[old_id].location)
if loc:
loc.remove_agent(old_id)
del sim.agents[old_id]
home = _find_player_apartment(sim)
persona = Persona(
id=player_id,
name=request.name,
age=request.age,
occupation=request.occupation,
gender=request.gender,
background=request.background,
home_location=home,
work_location="",
extraversion=request.extraversion,
agreeableness=request.agreeableness,
openness=request.openness,
)
agent = Agent(persona)
agent.is_player = True
sim.add_agent(agent)
await db.set_user_agent(user["username"], player_id)
return {"agent_id": player_id}
@router.put("/player/update")
async def player_update(request: PlayerUpdateRequest):
"""Update the player's persona fields in-place."""
user, agent = await _get_player_from_token(request.token)
if not agent:
raise HTTPException(status_code=404, detail="No player agent found")
p = agent.persona
if request.name is not None:
p.name = request.name
agent.name = request.name
if request.age is not None:
p.age = request.age
if request.occupation is not None:
p.occupation = request.occupation
if request.background is not None:
p.background = request.background
if request.gender is not None:
p.gender = request.gender
if request.extraversion is not None:
p.extraversion = max(1, min(10, request.extraversion))
if request.agreeableness is not None:
p.agreeableness = max(1, min(10, request.agreeableness))
if request.openness is not None:
p.openness = max(1, min(10, request.openness))
return {"ok": True}
@router.post("/player/move")
async def player_move(request: PlayerMoveRequest):
"""Move the player to a city location."""
from soci.api.server import get_simulation
from soci.agents.agent import AgentAction, AgentState
sim = get_simulation()
user, agent = await _get_player_from_token(request.token)
if not agent:
raise HTTPException(status_code=404, detail="No player agent found")
loc = sim.city.get_location(request.location)
if not loc:
raise HTTPException(status_code=400, detail=f"Unknown location: {request.location}")
action = AgentAction(type="move", target=request.location, detail=f"Walking to {loc.name}", duration_ticks=1)
await sim._execute_action(agent, action)
return {"ok": True, "location": agent.location}
@router.post("/player/talk")
async def player_talk(request: PlayerTalkRequest):
"""Send a message to an NPC and get their LLM-generated reply."""
from soci.api.server import get_simulation
from soci.actions.conversation import Conversation, ConversationTurn, continue_conversation
import uuid
sim = get_simulation()
user, player = await _get_player_from_token(request.token)
if not player:
raise HTTPException(status_code=404, detail="No player agent found")
target = sim.agents.get(request.target_id)
if not target:
raise HTTPException(status_code=404, detail="Target agent not found")
# Build a conversation with the player's message as the opening turn
conv_id = f"player_conv_{uuid.uuid4().hex[:8]}"
conv = Conversation(
id=conv_id,
location=player.location,
participants=[player.id, target.id],
topic="player interaction",
max_turns=20,
)
player_turn = ConversationTurn(
speaker_id=player.id,
speaker_name=player.name,
message=request.message,
tick=sim.clock.total_ticks,
)
conv.add_turn(player_turn)
# Let the NPC respond via LLM
reply_turn = await continue_conversation(conv, target, player, sim.llm, sim.clock)
# Update relationship
target.relationships.get_or_create(player.id, player.name)
player.relationships.get_or_create(target.id, target.name)
# Add memory to both
player.add_observation(
tick=sim.clock.total_ticks, day=sim.clock.day, time_str=sim.clock.time_str,
content=f"I said to {target.name}: \"{request.message}\"",
importance=4, involved_agents=[target.id],
)
if reply_turn and reply_turn.speaker_id == target.id:
target.add_observation(
tick=sim.clock.total_ticks, day=sim.clock.day, time_str=sim.clock.time_str,
content=f"{player.name} talked to me: \"{request.message}\"",
importance=4, involved_agents=[player.id],
)
return {"reply": reply_turn.message, "inner_thought": reply_turn.inner_thought}
return {"reply": "(no response)", "inner_thought": ""}
@router.post("/player/plan")
async def player_add_plan(request: PlayerPlanRequest):
"""Append an item to the player's daily plan."""
user, agent = await _get_player_from_token(request.token)
if not agent:
raise HTTPException(status_code=404, detail="No player agent found")
agent.daily_plan.append(request.plan_item)
return {"ok": True, "daily_plan": agent.daily_plan}
# ── Legacy join/action (kept for compatibility) ───────────────────────────────
@router.post("/player/join")
async def player_join(request: PlayerJoinRequest):
"""Register a human player as a new agent in the simulation."""
from soci.agents.agent import Agent
from soci.agents.persona import Persona
from soci.api.server import get_simulation
sim = get_simulation()
player_id = f"player_{request.name.lower().replace(' ', '_')}"
if player_id in sim.agents:
raise HTTPException(status_code=400, detail="Player already exists")
persona = Persona(
id=player_id,
name=request.name,
age=25,
occupation="newcomer",
background=request.background,
home_location="house_elena",
work_location="",
)
agent = Agent(persona)
agent.is_player = True
sim.add_agent(agent)
return {"id": player_id, "message": f"Welcome to Soci City, {request.name}!"}
@router.post("/player/{player_id}/action")
async def player_action(player_id: str, request: PlayerActionRequest):
"""Submit an action for a human player."""
from soci.agents.agent import AgentAction
from soci.actions.registry import resolve_action
from soci.api.server import get_simulation
sim = get_simulation()
agent = sim.agents.get(player_id)
if not agent or not agent.is_player:
raise HTTPException(status_code=404, detail="Player not found")
if agent.is_busy:
return {"status": "busy", "message": f"You're currently {agent.current_action.detail}"}
action = resolve_action(
{"action": request.action, "target": request.target, "detail": request.detail},
agent,
sim.city,
)
await sim._execute_action(agent, action)
return {
"status": "ok",
"action": action.to_dict(),
"location": agent.location,
}
@router.get("/relationships")
async def get_relationships():
"""Get the full relationship graph β€” all agent-to-agent connections."""
from soci.api.server import get_simulation
sim = get_simulation()
edges = []
seen = set()
for aid, agent in sim.agents.items():
for rel in agent.relationships.get_closest(20):
pair = tuple(sorted([aid, rel.agent_id]))
if pair in seen:
continue
seen.add(pair)
other_rel = None
other = sim.agents.get(rel.agent_id)
if other:
other_rel = other.relationships.get(aid)
edges.append({
"source": aid,
"target": rel.agent_id,
"source_name": agent.name,
"target_name": rel.agent_name,
"familiarity": round(rel.familiarity, 2),
"trust": round(rel.trust, 2),
"sentiment": round(rel.sentiment, 2),
"romantic_interest": round(rel.romantic_interest, 2),
"relationship_status": rel.relationship_status,
"mutual_romantic": round(other_rel.romantic_interest, 2) if other_rel else 0,
"interaction_count": rel.interaction_count,
})
return {"edges": edges}
@router.get("/events")
async def get_events(limit: int = 50):
"""Get recent simulation events for the event log."""
from soci.api.server import get_simulation
sim = get_simulation()
events = sim._event_history[-limit:]
return {"events": events}
@router.get("/controls")
async def get_controls():
"""Get current simulation control state."""
from soci.api.server import _sim_paused, _sim_speed, _llm_call_probability
return {"paused": _sim_paused, "speed": _sim_speed, "llm_call_probability": _llm_call_probability}
@router.post("/controls/llm_probability")
async def set_llm_probability(value: float = 0.10):
"""Set LLM call probability (0.0–1.0). Controls how often agents use LLM vs. routine behaviour."""
from soci.api.server import set_llm_call_probability
set_llm_call_probability(value)
from soci.api.server import _llm_call_probability
return {"llm_call_probability": _llm_call_probability}
@router.post("/controls/pause")
async def pause_simulation():
"""Pause the simulation."""
import soci.api.server as srv
srv._sim_paused = True
return {"paused": True}
@router.post("/controls/resume")
async def resume_simulation():
"""Resume the simulation."""
import soci.api.server as srv
srv._sim_paused = False
return {"paused": False}
@router.post("/controls/speed")
async def set_speed(multiplier: float = 1.0):
"""Set simulation speed. 0.02=50x, 0.1=10x, 0.2=5x, 0.5=2x, 1.0=normal, 3.0=slow."""
import soci.api.server as srv
srv._sim_speed = max(0.01, min(5.0, multiplier))
return {"speed": srv._sim_speed}
@router.post("/save")
async def save_state(name: str = "manual_save"):
"""Manually save the simulation state."""
from soci.api.server import get_simulation, get_database
sim = get_simulation()
db = get_database()
from soci.persistence.snapshots import save_simulation
await save_simulation(sim, db, name)
return {"status": "saved", "name": name, "tick": sim.clock.total_ticks}
# ── StockEx integration ──────────────────────────────────────────────────────
import os
import httpx
STOCKEX_URL = os.getenv("STOCKEX_URL", "https://raymelius-stockex.hf.space")
STOCKEX_API_KEY = os.getenv("STOCKEX_API_KEY", "soci-stockex-2024")
class StockExOrderRequest(BaseModel):
token: str # Soci player token
member_id: str # StockEx member ID (e.g. USR02)
symbol: str
side: str # BUY or SELL
quantity: int
price: float
@router.get("/stockex/market")
async def stockex_market():
"""Get current market data (best bid/offer) from StockEx."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{STOCKEX_URL}/ch/api/market")
if resp.status_code != 200:
raise HTTPException(status_code=502, detail="StockEx market unavailable")
return resp.json()
@router.get("/stockex/leaderboard")
async def stockex_leaderboard():
"""Get StockEx clearing house leaderboard."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{STOCKEX_URL}/ch/api/leaderboard")
if resp.status_code != 200:
raise HTTPException(status_code=502, detail="StockEx unavailable")
return resp.json()
@router.get("/stockex/portfolio/{member_id}")
async def stockex_portfolio(member_id: str):
"""Get a StockEx member's portfolio."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{STOCKEX_URL}/ch/api/member/{member_id}")
if resp.status_code != 200:
raise HTTPException(status_code=502, detail="StockEx member not found")
return resp.json()
@router.post("/stockex/order")
async def stockex_order(req: StockExOrderRequest):
"""Place a trade on StockEx on behalf of a Soci player agent."""
from soci.api.server import get_simulation
sim = get_simulation()
user, agent = await _get_player_from_token(req.token)
if not agent:
raise HTTPException(status_code=404, detail="Player agent not found")
# Place order on StockEx via API key
order_data = {
"api_key": STOCKEX_API_KEY,
"member_id": req.member_id,
"symbol": req.symbol.upper(),
"side": req.side.upper(),
"quantity": req.quantity,
"price": req.price,
}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(f"{STOCKEX_URL}/ch/api/order", json=order_data)
result = resp.json()
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail=result.get("error", "Order failed"))
# Record as life event for the agent
side_str = req.side.upper()
agent.add_life_event(
day=sim.clock.day, tick=sim.clock.total_ticks,
event_type="achievement",
description=f"Traded on StockEx: {side_str} {req.quantity} {req.symbol.upper()} @ €{req.price:.2f}",
)
agent.add_observation(
tick=sim.clock.total_ticks, day=sim.clock.day,
time_str=sim.clock.time_str,
content=f"I placed a {side_str} order for {req.quantity} shares of {req.symbol.upper()} at €{req.price:.2f} on StockEx.",
importance=7,
)
return {
"status": "ok",
"cl_ord_id": result.get("cl_ord_id"),
"detail": f"{side_str} {req.quantity} {req.symbol.upper()} @ €{req.price:.2f}",
}