Files
mirosociety/backend/app/api/simulate.py
T

682 lines
28 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import uuid
import time
from typing import AsyncGenerator
from fastapi import APIRouter, Request, HTTPException, Query
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 app.services.sim_logger import SimulationLogger
from app.services.simulation_logging import bind_simulation, unbind_simulation
from app.services.narrator import REPORT_SCHEMA_VERSION
from enum import Enum
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
def _serialize_event_data(data: dict) -> str:
def _default(obj):
if isinstance(obj, Enum):
return obj.value
return str(obj)
return json.dumps(data, default=_default)
class SegmentInput(BaseModel):
name: str
description: str
count: int | None = None
class SimulateRequest(BaseModel):
# 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
# 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):
event: str
class SpeedRequest(BaseModel):
mode: str
@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
request_log_token = bind_simulation(sim_id)
try:
await store.create(sim_id, rules)
logger.info(
"模拟 %s 已创建:规则长度=%d,人口=%d,持续天数=%d",
sim_id, len(rules), req.population, req.duration_days,
)
sim_log = SimulationLogger(sim_id)
sim_log.log_event("pipeline_started", {
"population": req.population,
"duration_days": req.duration_days,
"rules_chars": len(rules),
"has_proposed_change": bool(req.proposed_change),
"has_city": bool(req.city),
})
finally:
unbind_simulation(request_log_token)
cancelled = request.app.state.cancelled_sims
async def run_pipeline():
pipeline_log_token = bind_simulation(sim_id)
event_queue = request.app.state.event_queues.get(sim_id)
if not event_queue:
unbind_simulation(pipeline_log_token)
return
t_pipeline_start = time.monotonic()
t_stage = time.monotonic()
try:
await event_queue.put(SSEEvent(type="status", data={"status": "generating_world"}))
await store.update_status(sim_id, SimulationStatus.GENERATING_WORLD.value)
logger.info("模拟 %s:开始生成世界", sim_id)
blueprint = await world_gen.generate(
req.rules, req.population, req.duration_days,
proposed_change=req.proposed_change,
)
logger.info(
"模拟 %s:世界生成完成,耗时=%.2f秒,世界=%r",
sim_id, time.monotonic() - t_stage, blueprint.name,
)
sim_log.log_event("world_generated", {
"world_name": blueprint.name,
"elapsed_s": round(time.monotonic() - t_stage, 3),
"population": req.population,
"duration_days": req.duration_days,
})
t_stage = time.monotonic()
if sim_id in 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": translate("simulation_cancelled", locale)}))
return
demographics: DemographicProfile | None = None
if req.city:
try:
census = CensusService(request.app.state.llm)
if "," in req.city:
city_name, state = [p.strip() for p in req.city.split(",", 1)]
else:
city_name, state = req.city.strip(), None
demographics = await census.get_profile(city_name, state)
await event_queue.put(SSEEvent(type="demographics_loaded", data={
"city": demographics.city_name,
"state": demographics.state,
"population": demographics.population,
"median_income": demographics.median_household_income,
"poverty_rate": demographics.poverty_rate,
}))
except Exception as e:
logger.warning("城市人口统计数据获取失败(城市=%s):%s", req.city, e)
await event_queue.put(SSEEvent(type="status", data={"status": "generating_citizens"}))
await store.update_status(sim_id, SimulationStatus.GENERATING_CITIZENS.value)
logger.info(
"模拟 %s:开始生成居民,目标数量=%d",
sim_id, req.population,
)
async def on_citizen(agent):
if sim_id in cancelled:
raise asyncio.CancelledError()
await store.save_agent(sim_id, agent)
await event_queue.put(SSEEvent(type="citizen_generated", data=agent.model_dump()))
segments_dicts = [s.model_dump() for s in req.segments] if req.segments else None
agents = await citizen_gen.generate_fast(
blueprint, req.population, on_citizen=on_citizen,
proposed_change=req.proposed_change,
segments=segments_dicts,
demographics=demographics,
)
logger.info(
"模拟 %s:居民生成完成,耗时=%.2f秒,实际数量=%d",
sim_id, time.monotonic() - t_stage, len(agents),
)
fallback_count = sum(1 for agent in agents if agent.generation_source != "llm")
await store.set_meta(sim_id, "citizen_generation_fallback_count", fallback_count)
sim_log.log_event("citizens_generated", {
"count": len(agents),
"fallback_count": fallback_count,
"elapsed_s": round(time.monotonic() - t_stage, 3),
})
t_stage = time.monotonic()
for agent in agents:
await store.save_agent(sim_id, agent)
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": translate("simulation_cancelled", locale)}))
return
async def enrich_in_background():
try:
enriched = await citizen_gen.enrich_background(blueprint, agents)
engine.deliver_enriched_agents(sim_id, enriched)
for agent in enriched:
await store.save_agent(sim_id, agent)
logger.info("模拟 %s:居民背景信息补充完成", sim_id)
except Exception as e:
logger.warning("模拟 %s:居民背景信息补充失败:%s", sim_id, e)
asyncio.create_task(enrich_in_background())
world_state = WorldState(
blueprint=blueprint,
metrics=WorldMetrics(),
community_rules=[],
)
await store.update_status(sim_id, SimulationStatus.RUNNING.value)
logger.info(
"模拟 %s:开始运行,居民=%d,天数=%d",
sim_id, len(agents), req.duration_days,
)
sim_log.log_event("simulation_run_started", {
"agent_count": len(agents),
"duration_days": req.duration_days,
"rounds_per_day": world_state.blueprint.time_config.rounds_per_day,
"total_rounds_planned": world_state.blueprint.time_config.total_days * world_state.blueprint.time_config.rounds_per_day,
})
async def emit(event: SSEEvent):
await event_queue.put(event)
await engine.run(sim_id, world_state, agents, emit, sim_log=sim_log)
final_sim = await store.get_simulation(sim_id)
if final_sim and final_sim.get("status") == SimulationStatus.ERROR.value:
# SimulationEngine records the detailed exception and may
# still generate a partial report. Do not label this path as
# "completed" in the pipeline log, otherwise a failed run is
# easy to mistake for a successful one.
logger.error("模拟 %s:运行未完成,已保留可用的部分结果", sim_id)
sim_log.log_event("simulation_run_failed", {
"elapsed_s": round(time.monotonic() - t_stage, 3),
})
else:
logger.info(
"模拟 %s:运行完成,耗时=%.2f秒,总行动数=%d",
sim_id,
time.monotonic() - t_stage,
getattr(engine, "_total_actions", {}).get(sim_id, 0),
)
sim_log.log_event("simulation_run_complete", {
"elapsed_s": round(time.monotonic() - t_stage, 3),
})
logger.info(
"模拟 %s:全部流程完成,总耗时=%.2f秒",
sim_id, time.monotonic() - t_pipeline_start,
)
except asyncio.CancelledError:
logger.info("模拟 %s:流程已取消", sim_id)
await event_queue.put(SSEEvent(type="cancelled", data={"message": translate("simulation_cancelled", locale)}))
# Cancellation is signalled by the user (or an upper-layer kill).
# Make sure the DB reflects it so a future report fetch returns
# terminal status instead of an old in-flight value.
try:
await store.update_status(sim_id, SimulationStatus.CANCELLED.value)
sim_log.log_event("pipeline_cancelled", {})
except Exception as status_err:
logger.warning("模拟 %s:写入取消状态失败:%s", sim_id, status_err)
except Exception as e:
logger.error("模拟 %s:流程失败:%s", sim_id, e, exc_info=True)
await event_queue.put(SSEEvent(type="error", data={"message": str(e)}))
# Bug fix: a failure during world/citizen generation used to leave
# the simulation stuck in `generating_world`/`generating_citizens`
# because nothing flipped the status to a terminal value. Make
# sure clients polling /report get a clean terminal status, and
# the orphan sweep on next restart doesn't keep showing it as
# in-flight.
try:
await store.update_status(sim_id, SimulationStatus.ERROR.value)
sim_log.log_event("pipeline_failed", {
"error": str(e),
"stage_elapsed_s": round(time.monotonic() - t_stage, 3),
"total_elapsed_s": round(time.monotonic() - t_pipeline_start, 3),
})
except Exception as status_err:
logger.warning("模拟 %s:写入失败状态失败:%s", sim_id, status_err)
finally:
sim_log.close()
cancelled.discard(sim_id)
unbind_simulation(pipeline_log_token)
request.app.state.event_queues[sim_id] = asyncio.Queue()
asyncio.create_task(run_pipeline())
return {"simulation_id": sim_id}
@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:
# 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:
while True:
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(queue.get(), timeout=30)
yield {"event": event.type, "data": _serialize_event_data(event.data)}
if event.type in ("simulation_complete", "error", "cancelled"):
break
except asyncio.TimeoutError:
yield {"event": "keepalive", "data": "{}"}
finally:
request.app.state.event_queues.pop(sim_id, None)
return EventSourceResponse(event_generator())
@router.get("/simulation/{sim_id}/state")
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, 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)
return {
"simulation": sim,
"world_state": world_state.model_dump() if world_state else None,
"agent_count": len(agents),
}
@router.post("/simulation/{sim_id}/inject")
async def inject(sim_id: str, req: InjectRequest, request: Request):
engine = request.app.state.engine
store = request.app.state.store
world_state = await store.get_world_state(sim_id)
agents = await store.get_all_agents(sim_id)
if not 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}
@router.post("/simulation/{sim_id}/speed")
async def set_speed(sim_id: str, req: SpeedRequest, request: Request):
engine = request.app.state.engine
try:
mode = SpeedMode(req.mode)
except ValueError:
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}
@router.post("/simulation/{sim_id}/pause")
async def pause(sim_id: str, request: Request):
found = await request.app.state.engine.pause(sim_id)
if not found:
raise HTTPException(404, translate("simulation_not_running", locale_from_request(request)))
return {"status": "paused"}
@router.post("/simulation/{sim_id}/resume")
async def resume(sim_id: str, request: Request):
found = await request.app.state.engine.resume(sim_id)
if not found:
raise HTTPException(404, translate("simulation_not_running", locale_from_request(request)))
return {"status": "resumed"}
@router.post("/simulation/{sim_id}/stop")
async def stop(sim_id: str, request: Request):
found = await request.app.state.engine.stop(sim_id)
if not found:
raise HTTPException(404, translate("simulation_not_running", locale_from_request(request)))
return {"status": "stopped"}
@router.post("/simulation/{sim_id}/cancel")
async def cancel(sim_id: str, request: Request):
engine = request.app.state.engine
cancelled = request.app.state.cancelled_sims
stopped = await engine.stop(sim_id)
if stopped:
return {"status": "stopped"}
cancelled.add(sim_id)
return {"status": "cancelled"}
@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"
and result.get("report", {}).get("report_schema_version") == REPORT_SCHEMA_VERSION
):
report = result["report"]
report["rules_text"] = sim.get("rules_text", "") or ""
return report
if result.get("status") == "ready":
# A completed simulation may have a cached report from an older
# schema. Regenerate it once so report changes are not invisible to
# users who already ran the simulation.
narrator = getattr(request.app.state, "narrator", None)
if narrator is not None and hasattr(narrator, "set_locale"):
narrator.set_locale(locale)
report = await request.app.state.narrator.generate_report(sim_id, store)
if "error" not in report:
await store.save_report(sim_id, 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, "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}")
async def compare(sim_id: str, fork_id: str, request: Request):
store = request.app.state.store
narrator = request.app.state.narrator
source_state = await store.get_world_state(sim_id)
fork_state = await store.get_world_state(fork_id)
if not source_state or not fork_state:
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)
source_agents = await store.get_all_agents(sim_id)
fork_agents = await store.get_all_agents(fork_id)
async def _get_or_generate_report(sid):
cached = await store.get_report(sid)
if (
cached
and cached.get("status") == "ready"
and cached.get("report", {}).get("report_schema_version") == REPORT_SCHEMA_VERSION
):
return cached["report"]
rpt = await narrator.generate_report(sid, store)
if "error" not in rpt:
await store.save_report(sid, rpt)
return rpt
source_report = await _get_or_generate_report(sim_id)
fork_report = await _get_or_generate_report(fork_id)
return {
"source": {
"id": sim_id,
"report": source_report,
"metrics_history": source_metrics,
"final_day": source_state.day,
},
"fork": {
"id": fork_id,
"report": fork_report,
"metrics_history": fork_metrics,
"final_day": fork_state.day,
},
}
@router.get("/simulation/{sim_id}/forecast")
async def get_forecast(
sim_id: str,
request: Request,
horizon: int = Query(default=30, ge=1, le=365),
):
store = request.app.state.store
forecast = getattr(request.app.state, "forecast", None)
if not forecast or not forecast.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, translate("no_metrics_data", locale_from_request(request)))
analysis = forecast.analyze(metrics_history, horizon=horizon)
causal_links = forecast.discover_causality(sim_id)
counterfactuals = forecast.compute_counterfactuals(sim_id)
return {
"analysis": analysis,
"causal_links": [
{"cause": l.cause, "effect": l.effect, "lag": l.lag,
"p_value": l.p_value, "strength": l.strength}
for l in causal_links
],
"counterfactuals": [
{"event_round": c.event_round, "event_description": c.event_description,
"metric_impacts": c.metric_impacts}
for c in counterfactuals
],
}
@router.post("/simulation/{sim_id}/fork")
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, translate("source_simulation_not_found", locale_from_request(request)))
fork_id = str(uuid.uuid4())[:12]
source_world = await store.get_world_state(sim_id)
rounds_per_day = source_world.blueprint.time_config.rounds_per_day if source_world else 3
fork_round = req.fork_at_day * rounds_per_day
await store.create(fork_id, source.get("rules_text", ""))
await store.update_status(fork_id, SimulationStatus.PAUSED.value,
forked_from=sim_id,
world_name=source.get("world_name", ""),
agent_count=source.get("agent_count", 0))
await store.copy_state_at_round(sim_id, fork_id, fork_round)
await store.increment_fork_count(sim_id)
if req.changes:
world_state = await store.get_world_state(fork_id)
agents = await store.get_all_agents(fork_id)
if world_state and agents:
await engine.inject_event(fork_id, req.changes, world_state, agents)
event_queue = asyncio.Queue()
request.app.state.event_queues[fork_id] = event_queue
async def run_fork():
fork_log_token = bind_simulation(fork_id)
try:
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": translate("fork_state_not_found", locale)}))
return
await event_queue.put(SSEEvent(
type="status", data={"status": "forked_simulation"}
))
await event_queue.put(SSEEvent(
type="world_ready", data=world_state.blueprint.model_dump()
))
for agent in agents:
await event_queue.put(SSEEvent(
type="citizen_generated", data=agent.model_dump()
))
await store.update_status(
fork_id, SimulationStatus.RUNNING.value,
world_name=world_state.blueprint.name,
agent_count=len(agents),
)
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:
logger.error("分叉模拟 %s:流程失败:%s", fork_id, e, exc_info=True)
await event_queue.put(SSEEvent(type="error", data={"message": str(e)}))
finally:
unbind_simulation(fork_log_token)
asyncio.create_task(run_fork())
return {"simulation_id": fork_id, "forked_from": sim_id, "fork_at_day": req.fork_at_day}