++
This commit is contained in:
+123
-42
@@ -6,13 +6,14 @@ import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.models.simulation import SSEEvent, SimulationStatus, SpeedMode, ForkRequest
|
||||
from app.models.world import WorldState, WorldMetrics
|
||||
from app.models.demographics import DemographicProfile
|
||||
from app.services.census import CensusService
|
||||
from app.services.i18n import locale_from_request, translate
|
||||
|
||||
from enum import Enum
|
||||
|
||||
@@ -35,33 +36,20 @@ class SegmentInput(BaseModel):
|
||||
|
||||
|
||||
class SimulateRequest(BaseModel):
|
||||
rules: str
|
||||
# NOTE: We intentionally avoid field-level validators here because they run
|
||||
# before the request middleware has access to the Accept-Language header,
|
||||
# which means their messages would always be in English. Validation lives
|
||||
# in the route handler below so it can localize errors.
|
||||
rules: str = ""
|
||||
population: int = 25
|
||||
duration_days: int = 365
|
||||
proposed_change: str | None = None
|
||||
segments: list[SegmentInput] | None = None
|
||||
city: str | None = None
|
||||
|
||||
@field_validator("rules")
|
||||
@classmethod
|
||||
def rules_not_empty(cls, v):
|
||||
if not v or not v.strip():
|
||||
raise ValueError("Rules cannot be empty")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("population")
|
||||
@classmethod
|
||||
def population_range(cls, v):
|
||||
if v < 2 or v > 50:
|
||||
raise ValueError("Population must be between 2 and 50")
|
||||
return v
|
||||
|
||||
@field_validator("duration_days")
|
||||
@classmethod
|
||||
def duration_range(cls, v):
|
||||
if v < 1 or v > 365:
|
||||
raise ValueError("Duration must be between 1 and 365 days")
|
||||
return v
|
||||
# If true, the simulation is automatically marked is_public=1 as soon as
|
||||
# it finishes successfully, so it shows up in /gallery without a manual
|
||||
# publish click. Defaults to false so users opt in explicitly.
|
||||
auto_publish: bool = False
|
||||
|
||||
|
||||
class InjectRequest(BaseModel):
|
||||
@@ -74,13 +62,44 @@ class SpeedRequest(BaseModel):
|
||||
|
||||
@router.post("/simulate")
|
||||
async def simulate(req: SimulateRequest, request: Request):
|
||||
locale = locale_from_request(request)
|
||||
# Sync narrator's locale so generated narratives and reports use this language.
|
||||
narrator = getattr(request.app.state, "narrator", None)
|
||||
if narrator is not None and hasattr(narrator, "set_locale"):
|
||||
narrator.set_locale(locale)
|
||||
# Same for the world generator — world name, description, locations should
|
||||
# follow the user's language.
|
||||
world_gen = getattr(request.app.state, "world_generator", None)
|
||||
if world_gen is not None and hasattr(world_gen, "set_locale"):
|
||||
world_gen.set_locale(locale)
|
||||
# Citizen generator: agent names, roles, personality hooks should match.
|
||||
citizen_gen = getattr(request.app.state, "citizen_generator", None)
|
||||
if citizen_gen is not None and hasattr(citizen_gen, "set_locale"):
|
||||
citizen_gen.set_locale(locale)
|
||||
# Tension engine: hardcoded event descriptions ("Consumer patience wore
|
||||
# thin: ...") should localize too.
|
||||
engine_obj = getattr(request.app.state, "engine", None)
|
||||
if engine_obj is not None and hasattr(engine_obj, "tension"):
|
||||
engine_obj.tension.set_locale(locale)
|
||||
|
||||
# Re-validate here so error messages respect Accept-Language. Pydantic's
|
||||
# own field validators run before locale is available; we ignore those
|
||||
# errors and produce a localized one instead.
|
||||
rules = (req.rules or "").strip()
|
||||
if not rules:
|
||||
raise HTTPException(400, translate("rules_cannot_be_empty", locale))
|
||||
if req.population < 2 or req.population > 50:
|
||||
raise HTTPException(400, translate("population_range", locale))
|
||||
if req.duration_days < 1 or req.duration_days > 365:
|
||||
raise HTTPException(400, translate("duration_range", locale))
|
||||
|
||||
sim_id = str(uuid.uuid4())[:12]
|
||||
store = request.app.state.store
|
||||
world_gen = request.app.state.world_generator
|
||||
citizen_gen = request.app.state.citizen_generator
|
||||
engine = request.app.state.engine
|
||||
|
||||
await store.create(sim_id, req.rules)
|
||||
await store.create(sim_id, rules)
|
||||
|
||||
cancelled = request.app.state.cancelled_sims
|
||||
|
||||
@@ -99,17 +118,21 @@ async def simulate(req: SimulateRequest, request: Request):
|
||||
)
|
||||
|
||||
if sim_id in cancelled:
|
||||
await event_queue.put(SSEEvent(type="cancelled", data={"message": "Simulation cancelled"}))
|
||||
await event_queue.put(SSEEvent(type="cancelled", data={"message": translate("simulation_cancelled", locale)}))
|
||||
return
|
||||
|
||||
await store.set_meta(sim_id, "blueprint", blueprint.model_dump())
|
||||
if req.proposed_change:
|
||||
await store.set_meta(sim_id, "proposed_change", req.proposed_change)
|
||||
# Persist auto_publish so engine.run can flip is_public=1 when the
|
||||
# sim finishes, without having to thread the flag through every
|
||||
# method on the way down.
|
||||
await store.set_meta(sim_id, "auto_publish", req.auto_publish)
|
||||
await store.update_status(sim_id, SimulationStatus.GENERATING_WORLD.value, world_name=blueprint.name)
|
||||
await event_queue.put(SSEEvent(type="world_ready", data=blueprint.model_dump()))
|
||||
|
||||
if sim_id in cancelled:
|
||||
await event_queue.put(SSEEvent(type="cancelled", data={"message": "Simulation cancelled"}))
|
||||
await event_queue.put(SSEEvent(type="cancelled", data={"message": translate("simulation_cancelled", locale)}))
|
||||
return
|
||||
|
||||
demographics: DemographicProfile | None = None
|
||||
@@ -153,7 +176,7 @@ async def simulate(req: SimulateRequest, request: Request):
|
||||
await event_queue.put(SSEEvent(type="citizen_generated", data=agent.model_dump()))
|
||||
|
||||
if sim_id in cancelled:
|
||||
await event_queue.put(SSEEvent(type="cancelled", data={"message": "Simulation cancelled"}))
|
||||
await event_queue.put(SSEEvent(type="cancelled", data={"message": translate("simulation_cancelled", locale)}))
|
||||
return
|
||||
|
||||
async def enrich_in_background():
|
||||
@@ -183,7 +206,7 @@ async def simulate(req: SimulateRequest, request: Request):
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Pipeline cancelled for %s", sim_id)
|
||||
await event_queue.put(SSEEvent(type="cancelled", data={"message": "Simulation cancelled"}))
|
||||
await event_queue.put(SSEEvent(type="cancelled", data={"message": translate("simulation_cancelled", locale)}))
|
||||
except Exception as e:
|
||||
logger.error("Pipeline failed for %s: %s", sim_id, e, exc_info=True)
|
||||
await event_queue.put(SSEEvent(type="error", data={"message": str(e)}))
|
||||
@@ -198,9 +221,18 @@ async def simulate(req: SimulateRequest, request: Request):
|
||||
|
||||
@router.get("/simulation/{sim_id}/stream")
|
||||
async def stream(sim_id: str, request: Request):
|
||||
locale = locale_from_request(request)
|
||||
queue = request.app.state.event_queues.get(sim_id)
|
||||
if not queue:
|
||||
raise HTTPException(404, "Simulation not found")
|
||||
# Distinguish "no live pipeline" (e.g. after a backend restart) from
|
||||
# "never existed" so the client can give a useful message.
|
||||
sim = await request.app.state.store.get_simulation(sim_id)
|
||||
if not sim:
|
||||
raise HTTPException(404, translate("simulation_not_found", locale))
|
||||
raise HTTPException(
|
||||
410,
|
||||
translate("simulation_pipeline_gone", locale),
|
||||
)
|
||||
|
||||
async def event_generator() -> AsyncGenerator:
|
||||
try:
|
||||
@@ -225,7 +257,7 @@ async def get_state(sim_id: str, request: Request):
|
||||
store = request.app.state.store
|
||||
sim = await store.get_simulation(sim_id)
|
||||
if not sim:
|
||||
raise HTTPException(404, "Simulation not found")
|
||||
raise HTTPException(404, translate("simulation_not_found", locale_from_request(request)))
|
||||
|
||||
world_state = await store.get_world_state(sim_id)
|
||||
agents = await store.get_all_agents(sim_id)
|
||||
@@ -246,7 +278,7 @@ async def inject(sim_id: str, req: InjectRequest, request: Request):
|
||||
agents = await store.get_all_agents(sim_id)
|
||||
|
||||
if not world_state:
|
||||
raise HTTPException(404, "No world state")
|
||||
raise HTTPException(404, translate("no_world_state", locale_from_request(request)))
|
||||
|
||||
desc = await engine.inject_event(sim_id, req.event, world_state, agents)
|
||||
return {"description": desc}
|
||||
@@ -258,7 +290,7 @@ async def set_speed(sim_id: str, req: SpeedRequest, request: Request):
|
||||
try:
|
||||
mode = SpeedMode(req.mode)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"Invalid speed mode: {req.mode}")
|
||||
raise HTTPException(400, translate("invalid_speed_mode", locale_from_request(request), mode=req.mode))
|
||||
await engine.set_speed(sim_id, mode)
|
||||
return {"mode": mode.value}
|
||||
|
||||
@@ -267,7 +299,7 @@ async def set_speed(sim_id: str, req: SpeedRequest, request: Request):
|
||||
async def pause(sim_id: str, request: Request):
|
||||
found = await request.app.state.engine.pause(sim_id)
|
||||
if not found:
|
||||
raise HTTPException(404, "Simulation not running")
|
||||
raise HTTPException(404, translate("simulation_not_running", locale_from_request(request)))
|
||||
return {"status": "paused"}
|
||||
|
||||
|
||||
@@ -275,7 +307,7 @@ async def pause(sim_id: str, request: Request):
|
||||
async def resume(sim_id: str, request: Request):
|
||||
found = await request.app.state.engine.resume(sim_id)
|
||||
if not found:
|
||||
raise HTTPException(404, "Simulation not running")
|
||||
raise HTTPException(404, translate("simulation_not_running", locale_from_request(request)))
|
||||
return {"status": "resumed"}
|
||||
|
||||
|
||||
@@ -283,7 +315,7 @@ async def resume(sim_id: str, request: Request):
|
||||
async def stop(sim_id: str, request: Request):
|
||||
found = await request.app.state.engine.stop(sim_id)
|
||||
if not found:
|
||||
raise HTTPException(404, "Simulation not running")
|
||||
raise HTTPException(404, translate("simulation_not_running", locale_from_request(request)))
|
||||
return {"status": "stopped"}
|
||||
|
||||
|
||||
@@ -302,17 +334,59 @@ async def cancel(sim_id: str, request: Request):
|
||||
|
||||
@router.get("/simulation/{sim_id}/report")
|
||||
async def get_report(sim_id: str, request: Request):
|
||||
locale = locale_from_request(request)
|
||||
store = request.app.state.store
|
||||
sim = await store.get_simulation(sim_id)
|
||||
if not sim:
|
||||
raise HTTPException(404, translate("simulation_not_found", locale))
|
||||
|
||||
# Cancelled: client should not keep polling, but partial data (if any) is
|
||||
# still in the report table. Try to return it before bailing.
|
||||
if sim.get("status") == "cancelled":
|
||||
result = await store.get_report(sim_id)
|
||||
if result.get("status") == "ready":
|
||||
return result["report"]
|
||||
return {"status": "cancelled", "message": translate("simulation_cancelled", locale)}
|
||||
|
||||
# Interrupted (typically because the backend restarted and the pipeline
|
||||
# task died). If a partial report was already saved before the crash,
|
||||
# surface it — losing 30 rounds of work to a server restart is bad UX.
|
||||
# Only when there's no report do we return the interrupted terminal status.
|
||||
if sim.get("status") == "interrupted":
|
||||
result = await store.get_report(sim_id)
|
||||
if result.get("status") == "ready":
|
||||
return result["report"]
|
||||
return {
|
||||
"status": "interrupted",
|
||||
"message": translate("simulation_interrupted_no_report", locale),
|
||||
}
|
||||
|
||||
result = await store.get_report(sim_id)
|
||||
if result.get("status") == "ready":
|
||||
return result["report"]
|
||||
report = result["report"]
|
||||
report["rules_text"] = sim.get("rules_text", "") or ""
|
||||
return report
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/simulation/{sim_id}/publish")
|
||||
async def publish(sim_id: str, request: Request):
|
||||
locale = locale_from_request(request)
|
||||
sim = await request.app.state.store.get_simulation(sim_id)
|
||||
if not sim:
|
||||
raise HTTPException(404, translate("simulation_not_found", locale))
|
||||
await request.app.state.store.publish(sim_id)
|
||||
return {"published": True}
|
||||
return {"published": True, "is_public": True}
|
||||
|
||||
|
||||
@router.post("/simulation/{sim_id}/unpublish")
|
||||
async def unpublish(sim_id: str, request: Request):
|
||||
locale = locale_from_request(request)
|
||||
sim = await request.app.state.store.get_simulation(sim_id)
|
||||
if not sim:
|
||||
raise HTTPException(404, translate("simulation_not_found", locale))
|
||||
await request.app.state.store.unpublish(sim_id)
|
||||
return {"published": False, "is_public": False}
|
||||
|
||||
|
||||
@router.get("/simulation/{sim_id}/compare/{fork_id}")
|
||||
@@ -324,7 +398,7 @@ async def compare(sim_id: str, fork_id: str, request: Request):
|
||||
fork_state = await store.get_world_state(fork_id)
|
||||
|
||||
if not source_state or not fork_state:
|
||||
raise HTTPException(404, "Simulation not found")
|
||||
raise HTTPException(404, translate("simulation_not_found", locale_from_request(request)))
|
||||
|
||||
source_metrics = await store.get_metrics_history(sim_id)
|
||||
fork_metrics = await store.get_metrics_history(fork_id)
|
||||
@@ -370,11 +444,11 @@ async def get_forecast(
|
||||
forecast = getattr(request.app.state, "forecast", None)
|
||||
|
||||
if not forecast or not forecast.available:
|
||||
return {"error": "Forecast service not available"}
|
||||
return {"error": translate("forecast_not_available", locale_from_request(request))}
|
||||
|
||||
metrics_history = await store.get_metrics_history(sim_id)
|
||||
if not metrics_history:
|
||||
raise HTTPException(404, "No metrics data found")
|
||||
raise HTTPException(404, translate("no_metrics_data", locale_from_request(request)))
|
||||
|
||||
analysis = forecast.analyze(metrics_history, horizon=horizon)
|
||||
causal_links = forecast.discover_causality(sim_id)
|
||||
@@ -399,10 +473,11 @@ async def get_forecast(
|
||||
async def fork(sim_id: str, req: ForkRequest, request: Request):
|
||||
store = request.app.state.store
|
||||
engine = request.app.state.engine
|
||||
locale = locale_from_request(request)
|
||||
|
||||
source = await store.get_simulation(sim_id)
|
||||
if not source:
|
||||
raise HTTPException(404, "Source simulation not found")
|
||||
raise HTTPException(404, translate("source_simulation_not_found", locale_from_request(request)))
|
||||
|
||||
fork_id = str(uuid.uuid4())[:12]
|
||||
source_world = await store.get_world_state(sim_id)
|
||||
@@ -431,7 +506,7 @@ async def fork(sim_id: str, req: ForkRequest, request: Request):
|
||||
world_state = await store.get_world_state(fork_id)
|
||||
agents = await store.get_all_agents(fork_id)
|
||||
if not world_state or not agents:
|
||||
await event_queue.put(SSEEvent(type="error", data={"message": "Fork state not found"}))
|
||||
await event_queue.put(SSEEvent(type="error", data={"message": translate("fork_state_not_found", locale)}))
|
||||
return
|
||||
|
||||
await event_queue.put(SSEEvent(
|
||||
@@ -456,6 +531,12 @@ async def fork(sim_id: str, req: ForkRequest, request: Request):
|
||||
async def emit(event: SSEEvent):
|
||||
await event_queue.put(event)
|
||||
|
||||
# Keep tension engine's locale in sync with the fork request —
|
||||
# otherwise the hardcoded "Consumer patience wore thin: ..." line
|
||||
# will surface in English even for zh users.
|
||||
if hasattr(engine, "tension"):
|
||||
engine.tension.set_locale(locale)
|
||||
|
||||
await engine.run(fork_id, world_state, agents, emit, start_round=fork_round)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user