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
+71 -5
View File
@@ -1,11 +1,21 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.presets import PRESETS
from app.services.i18n import DEFAULT_LOCALE, locale_from_request
router = APIRouter(prefix="/api")
router = APIRouter(prefix="/api")
CUSTOM_SIMULATION_DIR = Path(__file__).resolve().parents[3] / "custom_simulation"
class CustomPresetPayload(BaseModel):
title: str = Field(min_length=1, max_length=160)
content: str = Field(min_length=1)
def _localize(value, locale: str):
@@ -30,6 +40,62 @@ def _localize(value, locale: str):
@router.get("/presets")
async def get_presets(request: Request):
locale = locale_from_request(request)
return {"presets": _localize(PRESETS, locale)}
async def get_presets(request: Request):
locale = locale_from_request(request)
return {"presets": _localize(PRESETS, locale)}
def _custom_presets():
CUSTOM_SIMULATION_DIR.mkdir(parents=True, exist_ok=True)
presets = []
for path in sorted(CUSTOM_SIMULATION_DIR.glob("*.txt"), key=lambda p: p.name.lower()):
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
presets.append({
"id": f"custom:{path.name}",
"name": path.stem,
"rules": content,
"custom": True,
"filename": path.name,
})
return presets
@router.get("/custom-presets")
async def get_custom_presets():
"""Load user-authored scenario files from the project custom_simulation folder."""
return {"presets": _custom_presets()}
@router.post("/custom-presets")
async def create_custom_preset(payload: CustomPresetPayload):
"""Save a custom scenario, suffixing duplicate titles with _1, _2, ... ."""
title = " ".join(payload.title.strip().split())
# Keep unicode letters (including Chinese) while preventing path traversal.
title = title.replace("/", "/").replace("\\", "\")
if not title or title in {".", ".."}:
raise HTTPException(status_code=400, detail="Title is required")
CUSTOM_SIMULATION_DIR.mkdir(parents=True, exist_ok=True)
candidate = CUSTOM_SIMULATION_DIR / f"{title}.txt"
index = 1
while candidate.exists():
candidate = CUSTOM_SIMULATION_DIR / f"{title}_{index}.txt"
index += 1
try:
candidate.write_text(payload.content, encoding="utf-8")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Unable to save preset: {exc}") from exc
return {
"preset": {
"id": f"custom:{candidate.name}",
"name": candidate.stem,
"rules": payload.content,
"custom": True,
"filename": candidate.name,
}
}
+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())