This commit is contained in:
2026-09-27 23:06:24 +08:00
parent 2d4f9230b1
commit 22450e5bb3
40 changed files with 3040 additions and 697 deletions
+163 -53
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
import asyncio
import json
import uuid
import logging
from typing import AsyncGenerator
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
@@ -12,8 +13,10 @@ 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.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 enum import Enum
@@ -97,25 +100,57 @@ async def simulate(req: SimulateRequest, request: Request):
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, rules)
cancelled = request.app.state.cancelled_sims
async def run_pipeline():
event_queue = request.app.state.event_queues.get(sim_id)
if not event_queue:
return
try:
await event_queue.put(SSEEvent(type="status", data={"status": "generating_world"}))
await store.update_status(sim_id, SimulationStatus.GENERATING_WORLD.value)
blueprint = await world_gen.generate(
req.rules, req.population, req.duration_days,
proposed_change=req.proposed_change,
)
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)}))
@@ -152,10 +187,14 @@ async def simulate(req: SimulateRequest, request: Request):
"poverty_rate": demographics.poverty_rate,
}))
except Exception as e:
logger.warning("Demographics fetch failed for %s: %s", req.city, 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)
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:
@@ -164,12 +203,21 @@ async def simulate(req: SimulateRequest, request: Request):
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,
)
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),
)
sim_log.log_event("citizens_generated", {
"count": len(agents),
"elapsed_s": round(time.monotonic() - t_stage, 3),
})
t_stage = time.monotonic()
for agent in agents:
await store.save_agent(sim_id, agent)
@@ -185,9 +233,9 @@ async def simulate(req: SimulateRequest, request: Request):
engine.deliver_enriched_agents(sim_id, enriched)
for agent in enriched:
await store.save_agent(sim_id, agent)
logger.info("Background enrichment complete for %s", sim_id)
logger.info("模拟 %s:居民背景信息补充完成", sim_id)
except Exception as e:
logger.warning("Background enrichment failed for %s: %s", sim_id, e)
logger.warning("模拟 %s:居民背景信息补充失败:%s", sim_id, e)
asyncio.create_task(enrich_in_background())
@@ -197,21 +245,80 @@ async def simulate(req: SimulateRequest, request: Request):
community_rules=[],
)
await store.update_status(sim_id, SimulationStatus.RUNNING.value)
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)
except asyncio.CancelledError:
logger.info("Pipeline cancelled for %s", sim_id)
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)}))
finally:
cancelled.discard(sim_id)
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())
@@ -501,8 +608,9 @@ async def fork(sim_id: str, req: ForkRequest, request: Request):
event_queue = asyncio.Queue()
request.app.state.event_queues[fork_id] = event_queue
async def run_fork():
try:
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:
@@ -539,9 +647,11 @@ async def fork(sim_id: str, req: ForkRequest, request: Request):
await engine.run(fork_id, world_state, agents, emit, start_round=fork_round)
except Exception as e:
logger.error("Fork pipeline failed for %s: %s", fork_id, e, exc_info=True)
await event_queue.put(SSEEvent(type="error", data={"message": str(e)}))
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())