diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..0725257
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,27 @@
+.git
+.gitignore
+.DS_Store
+.env
+.env.*
+!.env.example
+
+data/
+logs/
+backend/logs/
+tmp/
+.run/
+
+backend/.venv/
+backend/__pycache__/
+frontend/node_modules/
+frontend/dist/
+node_modules/
+**/__pycache__/
+**/*.py[cod]
+*.log
+*.db
+*.sqlite
+*.sqlite3
+
+docs/
+skills/
diff --git a/.gitignore b/.gitignore
index b8b15dc..58aeb68 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,15 +2,19 @@ __pycache__/
*.py[cod]
*$py.class
*.so
-.env
+.env
+.env.*
+!.env.example
.venv/
venv/
env/
node_modules/
dist/
.DS_Store
-data/
-*.db
+data/
+/logs/
+/backend/logs/
+*.db
*.egg-info/
build/
*.log
@@ -28,7 +32,10 @@ frontend/node_modules/.vite/
# 本地启动/部署日志
backend.log
frontend.log
-.run/
+.run/
+
+# 临时工作目录
+/tmp/
# 临时文件
*.swp
diff --git a/Dockerfile b/Dockerfile
index ad9e284..664e959 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,14 +5,17 @@ RUN npm ci
COPY frontend/ .
RUN npm run build
-FROM python:3.12-slim
-WORKDIR /app
-RUN apt-get update && apt-get install -y --no-install-recommends \
- gcc libcurl4-openssl-dev libssl-dev \
- && rm -rf /var/lib/apt/lists/*
-COPY backend/ backend/
-COPY --from=frontend /app/frontend/dist backend/app/static
-RUN pip install --no-cache-dir -e backend/
-ENV DATABASE_DIR=/app/data
-EXPOSE 8000
-CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}
+FROM python:3.12-slim
+WORKDIR /app
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ gcc libcurl4-openssl-dev libssl-dev \
+ && rm -rf /var/lib/apt/lists/*
+COPY backend/ backend/
+COPY custom_simulation/ custom_simulation/
+COPY --from=frontend /app/frontend/dist backend/app/static
+RUN pip install --no-cache-dir -e backend/
+ENV DATABASE_DIR=/app/data
+ENV PYTHONUNBUFFERED=1
+RUN mkdir -p /app/data /app/logs /app/backend/logs /app/custom_simulation
+EXPOSE 8000
+CMD ["sh", "-c", "exec uvicorn app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-8000}"]
diff --git a/README.md b/README.md
index d75491f..505e700 100644
--- a/README.md
+++ b/README.md
@@ -300,13 +300,21 @@ Works with any OpenAI-compatible API: OpenAI, Anthropic (via proxy), Ollama, vLL
## Deployment
-### Docker Compose (recommended)
-
-```bash
-cp .env.example .env
-# Edit .env with your API key
-docker compose up --build
-```
+### Docker Compose (recommended)
+
+```bash
+cp .env.example .env
+# Edit .env with your API key
+mkdir -p data logs backend/logs custom_simulation
+docker compose up -d --build
+```
+
+The service listens on port `8000` by default (override the host port with
+`APP_PORT=8080`). SQLite data, human-readable simulation logs, structured
+logs, and custom scenario files are bind-mounted from the project directory.
+Check the deployment with `docker compose ps` and
+`curl http://127.0.0.1:8000/health`; follow logs with
+`docker compose logs -f app`.
### Render
diff --git a/backend/app/api/presets.py b/backend/app/api/presets.py
index da7766e..831ea3d 100644
--- a/backend/app/api/presets.py
+++ b/backend/app/api/presets.py
@@ -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)}
\ No newline at end of file
+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,
+ }
+ }
diff --git a/backend/app/api/simulate.py b/backend/app/api/simulate.py
index a5e27f0..34a6c1b 100644
--- a/backend/app/api/simulate.py
+++ b/backend/app/api/simulate.py
@@ -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())
diff --git a/backend/app/db/store.py b/backend/app/db/store.py
index 1c2478a..5e9af41 100644
--- a/backend/app/db/store.py
+++ b/backend/app/db/store.py
@@ -78,22 +78,24 @@ class SimulationStore:
await db.execute(f"UPDATE simulations SET {', '.join(sets)} WHERE id = ?", vals)
await db.commit()
- async def sweep_orphan_running_sims(self) -> int:
- """Mark any 'running' simulations as 'interrupted' on startup.
-
- Running simulations live only in the current process — there is no
- supervisor that resumes them after a restart. If we don't sweep them,
- their status would stay 'running' forever and the report endpoint
- would never return a terminal state.
-
- Returns the number of sims swept.
- """
- async with aiosqlite.connect(self.global_db_path) as db:
- cursor = await db.execute(
- "UPDATE simulations SET status = 'interrupted' WHERE status = 'running'"
- )
- await db.commit()
- return cursor.rowcount or 0
+ async def sweep_orphan_running_sims(self) -> int:
+ """Mark any in-flight (non-terminal) simulation as 'interrupted' on
+ startup. In-flight simulations live only in the current process —
+ there is no supervisor that resumes them after a restart. If we
+ don't sweep them, their status would stay stuck in
+ `generating_world` / `generating_citizens` / `running` / `paused`
+ forever and the report endpoint would never return a terminal
+ state.
+
+ Returns the number of sims swept.
+ """
+ async with aiosqlite.connect(self.global_db_path) as db:
+ cursor = await db.execute(
+ "UPDATE simulations SET status = 'interrupted' "
+ "WHERE status NOT IN ('completed', 'error', 'cancelled', 'interrupted')"
+ )
+ await db.commit()
+ return cursor.rowcount or 0
async def get_simulation(self, sim_id: str) -> dict | None:
async with aiosqlite.connect(self.global_db_path) as db:
diff --git a/backend/app/main.py b/backend/app/main.py
index 61f8775..e1a0281 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -27,16 +27,35 @@ from app.services.i18n import parse_accept_language
from app.api.simulate import router as simulate_router
from app.api.agents import router as agents_router
from app.api.gallery import router as gallery_router
-from app.api.presets import router as presets_router
-
-logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
-logger = logging.getLogger(__name__)
+from app.api.presets import router as presets_router
+from app.services.simulation_logging import (
+ ChineseSimulationFormatter,
+ install_simulation_log_handler,
+)
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
+install_simulation_log_handler()
+# Use Chinese labels for the terminal's timestamp/level/module metadata too;
+# the original message is kept intact so stack traces and search terms remain
+# useful when diagnosing third-party libraries.
+for _handler in logging.getLogger().handlers:
+ if not isinstance(_handler, logging.NullHandler):
+ _handler.setFormatter(ChineseSimulationFormatter())
+logger = logging.getLogger(__name__)
@asynccontextmanager
-async def lifespan(app: FastAPI):
- logs_dir = Path(__file__).resolve().parent.parent / "logs"
- logs_dir.mkdir(parents=True, exist_ok=True)
+async def lifespan(app: FastAPI):
+ # Keep user-authored scenario files available even before the first
+ # presets request (and create the folder on a fresh checkout).
+ custom_simulation_dir = Path(__file__).resolve().parents[2] / "custom_simulation"
+ custom_simulation_dir.mkdir(parents=True, exist_ok=True)
+ logs_dir = Path(__file__).resolve().parent.parent / "logs"
+ logs_dir.mkdir(parents=True, exist_ok=True)
+ # Project-level text logs are created per simulation, but ensure the
+ # directory exists as soon as the application starts.
+ project_logs_dir = Path(__file__).resolve().parents[2] / "logs"
+ project_logs_dir.mkdir(parents=True, exist_ok=True)
store = SimulationStore(settings.database_dir)
await store.init()
@@ -48,9 +67,9 @@ async def lifespan(app: FastAPI):
try:
interrupted = await store.sweep_orphan_running_sims()
if interrupted:
- logger.warning("Marked %d orphan simulations as interrupted", interrupted)
+ logger.warning("已将 %d 个遗留模拟标记为已中断", interrupted)
except Exception as e: # pragma: no cover - best effort
- logger.warning("Orphan sweep failed: %s", e)
+ logger.warning("清理遗留模拟失败:%s", e)
llm = LLMClient(
api_key=settings.llm_api_key,
@@ -84,9 +103,9 @@ async def lifespan(app: FastAPI):
app.state.event_queues = {}
app.state.cancelled_sims = set()
- logger.info("MiroSociety started — LLM: %s @ %s", settings.llm_model, settings.llm_base_url)
+ logger.info("MiroSociety 已启动:模型=%s,地址=%s", settings.llm_model, settings.llm_base_url)
yield
- logger.info("MiroSociety shutting down")
+ logger.info("MiroSociety 正在关闭")
app = FastAPI(title="MiroSociety", version="0.1.0", lifespan=lifespan)
diff --git a/backend/app/services/census.py b/backend/app/services/census.py
index 69c7eea..569751d 100644
--- a/backend/app/services/census.py
+++ b/backend/app/services/census.py
@@ -228,7 +228,7 @@ class CensusService:
)
raw = await self.llm.generate(system, user, json_mode=True, max_tokens=800)
- data = parse_json(raw)
+ data = parse_json(raw, purpose="城市人口统计")
return DemographicProfile(
city_name=city.title(),
diff --git a/backend/app/services/citizen_generator.py b/backend/app/services/citizen_generator.py
index 0f27c1f..b6f1175 100644
--- a/backend/app/services/citizen_generator.py
+++ b/backend/app/services/citizen_generator.py
@@ -378,14 +378,15 @@ class CitizenGenerator:
)
user_text += f"\n\nTARGET SEGMENTS (distribute citizens across these):\n{seg_text}"
- response = await self.llm.generate(
- system=system_prompt,
- user=user_text,
- json_mode=True,
- max_tokens=4000,
- )
-
- data = parse_json(response)
+ response = await self.llm.generate(
+ system=system_prompt,
+ user=user_text,
+ json_mode=True,
+ max_tokens=4000,
+ purpose="cast_generate",
+ )
+
+ data = parse_json(response, purpose="居民名单生成")
if isinstance(data, dict) and "citizens" in data:
cast = data["citizens"]
elif isinstance(data, list):
@@ -400,25 +401,26 @@ class CitizenGenerator:
and cast
and not any(self._has_cjk(str(c.get("name", ""))) for c in cast)
):
- logger.warning("Cast generation returned non-CJK names despite zh locale, retrying. Names: %s", [c.get('name') for c in cast])
+ logger.warning("中文模式下居民姓名仍非中文,将重试。当前姓名:%s", [c.get('name') for c in cast])
retry_user = user_text + (
"\n\n[STRICT REMINDER] All names, roles, personality hooks, and "
"stances MUST be written in Chinese characters. Do NOT use English."
)
- response = await self.llm.generate(
- system=system_prompt,
- user=retry_user,
- json_mode=True,
- max_tokens=3000,
- )
- data = parse_json(response)
+ response = await self.llm.generate(
+ system=system_prompt,
+ user=retry_user,
+ json_mode=True,
+ max_tokens=3000,
+ purpose="cast_generate_retry",
+ )
+ data = parse_json(response, purpose="居民名单生成重试")
if isinstance(data, dict) and "citizens" in data:
cast = data["citizens"]
elif isinstance(data, list):
cast = data
if len(cast) < count:
- logger.warning("Cast generation returned %d/%d citizens, padding", len(cast), count)
+ logger.warning("居民生成仅返回 %d/%d 人,将使用默认居民补齐", len(cast), count)
while len(cast) < count:
idx = len(cast)
if self._locale == "zh":
@@ -468,12 +470,31 @@ class CitizenGenerator:
)
prompts.append((self._directive(PERSONA_SYSTEM_PROMPT), f"{world_ctx}\n\n{citizen_ctx}"))
- responses = await self.llm.generate_batch(prompts, json_mode=True, max_tokens=800)
+ # A full profile contains a backstory, goals, beliefs, and
+ # resources. 800 tokens is too small for Chinese output and can
+ # truncate the JSON before the closing braces, causing avoidable
+ # parse failures.
+ responses = await self.llm.generate_batch(
+ prompts,
+ json_mode=True,
+ max_tokens=1400,
+ purpose="persona_fast",
+ )
+ # Sub-purpose differentiates persona_fast vs persona_enrich so
+ # timing stats aren't mixed together.
+ _persona_purpose = "persona_fast"
+ # Sub-purpose: differentiate "persona_fast" from "persona_enrich"
+ # so timing stats don't get bucketed together with the slow
+ # variants.
+ # (this is the persona_fast path — the enrich path is below)
+ # Sub-purpose: differentiate "persona fast" from "persona enrich" so
+ # timing stats don't get bucketed together with the slow variants.
+ # Callers above set this; default to persona_fast if unset.
for i, response in enumerate(responses):
idx = batch_start + i
member = cast[idx]
- data = parse_json(response)
+ data = parse_json(response, purpose=f"居民画像生成(批次索引={batch_start + i})")
personality_data = data.get("personality", {})
personality = Personality(
@@ -536,7 +557,7 @@ class CitizenGenerator:
max_tokens=3000,
)
- data = parse_json(response)
+ data = parse_json(response, purpose="居民关系生成")
if isinstance(data, dict) and "relationships" in data:
rel_list = data["relationships"]
elif isinstance(data, list):
@@ -624,7 +645,7 @@ class CitizenGenerator:
for i, response in enumerate(responses):
agent = batch[i]
- data = parse_json(response)
+ data = parse_json(response, purpose=f"居民人生经历生成(居民={agent.name},ID={agent.id})")
agent.life_state = self._parse_life_history(data, agent)
def _parse_life_history(self, data: dict, agent: AgentPersona) -> LifeState:
diff --git a/backend/app/services/engine.py b/backend/app/services/engine.py
index 69e9ad0..d5e0525 100644
--- a/backend/app/services/engine.py
+++ b/backend/app/services/engine.py
@@ -1,8 +1,9 @@
-from __future__ import annotations
-import asyncio
-import logging
-import random
-from typing import Callable, Awaitable, Any
+from __future__ import annotations
+import asyncio
+import logging
+import random
+import time
+from typing import Callable, Awaitable, Any
from app.models.agent import AgentPersona, COMMUNICATION_STYLES
from app.models.action import ActionType, AgentDecision, ActionEntry, ReactiveResponse
@@ -351,32 +352,50 @@ class SimulationEngine:
agent.social_connections = src.social_connections
if src.relationships:
agent.relationships = src.relationships
- logger.info("Enriched %d agents for sim %s", len(enriched_map), simulation_id)
+ logger.info("模拟 %s:已补充 %d 名居民的背景信息", simulation_id, len(enriched_map))
return agents
async def run(
self,
simulation_id: str,
- world_state: WorldState,
- agents: list[AgentPersona],
- emit: Callable[[SSEEvent], Awaitable[None]],
- start_round: int = 0,
- ):
+ world_state: WorldState,
+ agents: list[AgentPersona],
+ emit: Callable[[SSEEvent], Awaitable[None]],
+ start_round: int = 0,
+ sim_log: SimulationLogger | None = None,
+ ):
self._running[simulation_id] = True
self._paused[simulation_id] = asyncio.Event()
self._paused[simulation_id].set()
self._speed[simulation_id] = SpeedMode.LIVE
self._total_actions[simulation_id] = 0
self._last_narrative[simulation_id] = ""
- self._enrichment_ready[simulation_id] = asyncio.Event()
- self.gossip.init_simulation(simulation_id)
- if self.forecast:
- self.forecast.init_simulation(simulation_id)
-
- sim_log = SimulationLogger(simulation_id)
-
- total_rounds = world_state.blueprint.time_config.total_days * world_state.blueprint.time_config.rounds_per_day
- round_num = start_round
+ self._enrichment_ready[simulation_id] = asyncio.Event()
+ self.gossip.init_simulation(simulation_id)
+ if self.forecast:
+ self.forecast.init_simulation(simulation_id)
+
+ owns_sim_log = sim_log is None
+ sim_log = sim_log or SimulationLogger(simulation_id)
+ total_rounds = world_state.blueprint.time_config.total_days * world_state.blueprint.time_config.rounds_per_day
+ # Heartbeat config: surface a low-volume progress line + sim_log
+ # milestone every `HEARTBEAT_EVERY` rounds so a stalled run doesn't
+ # go silent. Keep this cheap — it must not gate the hot loop.
+ HEARTBEAT_EVERY = 25
+ t_engine_start = time.monotonic()
+ tension_event_count = 0
+ logger.info(
+ "模拟 %s:引擎开始运行,总轮数=%d,居民=%d,起始轮次=%d",
+ simulation_id, total_rounds, len(agents), start_round,
+ )
+ sim_log.log_event("engine_started", {
+ "total_rounds": total_rounds,
+ "agent_count": len(agents),
+ "start_round": start_round,
+ "rounds_per_day": world_state.blueprint.time_config.rounds_per_day,
+ })
+
+ round_num = start_round
await self.store.save_world_state(simulation_id, round_num, world_state)
await self.store.save_agents_batch(simulation_id, agents)
@@ -415,7 +434,7 @@ class SimulationEngine:
if len(agent.working_memory) > 9:
agent.working_memory = agent.working_memory[-9:]
except Exception as life_err:
- logger.warning("LifeEngine failed for round %d: %s", round_num, life_err)
+ logger.warning("第 %d 轮生活引擎失败:%s", round_num, life_err)
decisions = await self._batch_decisions(active, world_state, agents, simulation_id)
@@ -480,7 +499,7 @@ class SimulationEngine:
tod = TIMES_OF_DAY[world_state.round_in_day % 3]
await self.store.save_narrative(simulation_id, round_num, world_state.day, tod, narrative)
except Exception as narr_err:
- logger.warning("Narrator failed for round %d: %s", round_num, narr_err)
+ logger.warning("第 %d 轮叙事生成失败:%s", round_num, narr_err)
for agent, event_desc in life_events_this_round:
await emit(SSEEvent(type="life_event", data={
@@ -540,7 +559,7 @@ class SimulationEngine:
for anomaly in anomalies:
await emit(SSEEvent(type="anomaly_detected", data=anomaly))
except Exception as anom_err:
- logger.debug("Anomaly check failed: %s", anom_err)
+ logger.debug("异常检查失败:%s", anom_err)
if round_num % 2 == 0 and round_num >= 3:
try:
@@ -558,7 +577,7 @@ class SimulationEngine:
"contradictions": cs.contradictions,
}))
except Exception as coh_err:
- logger.debug("Coherence scoring failed: %s", coh_err)
+ logger.debug("一致性评分失败:%s", coh_err)
if round_num == 30 or (round_num > 30 and round_num % 30 == 0):
try:
@@ -595,11 +614,39 @@ class SimulationEngine:
cross_agent_repeated=cross_rep_str,
)
except Exception as log_err:
- logger.debug("SimulationLogger failed: %s", log_err)
+ logger.debug("模拟日志写入失败:%s", log_err)
- significance = self._count_significant(resolved_entries)
-
- await emit(SSEEvent(
+ significance = self._count_significant(resolved_entries)
+ if tension_event:
+ tension_event_count += 1
+
+ # Heartbeat + milestone. Only every HEARTBEAT_EVERY rounds so
+ # this stays out of the hot path. Round 1 also gets a line so
+ # you see *something* happen immediately after engine.run
+ # starts.
+ if round_num == 1 or round_num % HEARTBEAT_EVERY == 0 or round_num == total_rounds:
+ elapsed = time.monotonic() - t_engine_start
+ avg = elapsed / max(1, round_num)
+ logger.info(
+ "模拟 %s:进度 轮次=%d/%d,天数=%d,活跃居民=%d,总行动=%d,张力事件=%d,已耗时=%.1f秒,平均=%.2f秒/轮",
+ simulation_id, round_num, total_rounds,
+ world_state.day, len(active),
+ self._total_actions.get(simulation_id, 0),
+ tension_event_count, elapsed, avg,
+ )
+ if round_num == 1 or round_num % 50 == 0:
+ try:
+ sim_log.log_event("progress_milestone", {
+ "round": round_num,
+ "day": world_state.day,
+ "elapsed_s": round(time.monotonic() - t_engine_start, 3),
+ "tension_events_so_far": tension_event_count,
+ "actions_total": self._total_actions.get(simulation_id, 0),
+ })
+ except Exception:
+ pass
+
+ await emit(SSEEvent(
type="round_complete",
data={
"round": round_num,
@@ -662,31 +709,58 @@ class SimulationEngine:
try:
if await self.store.get_meta(simulation_id, "auto_publish"):
await self.store.publish(simulation_id)
- logger.info("Auto-published sim %s to gallery", simulation_id)
+ logger.info("模拟 %s:已自动发布到画廊", simulation_id)
except Exception as e:
- logger.warning("Auto-publish failed for %s: %s", simulation_id, e)
- asyncio.create_task(self._generate_report_background(simulation_id))
-
- except Exception as e:
- logger.error("Simulation %s failed: %s", simulation_id, e, exc_info=True)
- await emit(SSEEvent(type="error", data={"message": str(e)}))
- await self.store.update_status(simulation_id, SimulationStatus.ERROR.value)
- if self._total_actions.get(simulation_id, 0) > 0:
- asyncio.create_task(self._generate_report_background(simulation_id))
- finally:
- sim_log.close()
+ logger.warning("模拟 %s:自动发布到画廊失败:%s", simulation_id, e)
+ asyncio.create_task(self._generate_report_background(simulation_id))
+ logger.info(
+ "模拟 %s:引擎运行完成,轮数=%d,天数=%d,行动数=%d,张力事件=%d,耗时=%.1f秒",
+ simulation_id, round_num, world_state.day,
+ self._total_actions.get(simulation_id, 0),
+ tension_event_count, time.monotonic() - t_engine_start,
+ )
+ try:
+ sim_log.log_event("engine_finished", {
+ "round": round_num,
+ "day": world_state.day,
+ "actions_total": self._total_actions.get(simulation_id, 0),
+ "tension_events_total": tension_event_count,
+ "elapsed_s": round(time.monotonic() - t_engine_start, 3),
+ })
+ except Exception:
+ pass
+
+ except Exception as e:
+ logger.error("模拟 %s:引擎运行失败:%s", simulation_id, e, exc_info=True)
+ await emit(SSEEvent(type="error", data={"message": str(e)}))
+ await self.store.update_status(simulation_id, SimulationStatus.ERROR.value)
+ if self._total_actions.get(simulation_id, 0) > 0:
+ asyncio.create_task(self._generate_report_background(simulation_id))
+ try:
+ sim_log.log_event("engine_failed", {
+ "round": round_num,
+ "day": world_state.day,
+ "actions_total": self._total_actions.get(simulation_id, 0),
+ "elapsed_s": round(time.monotonic() - t_engine_start, 3),
+ "error": str(e),
+ })
+ except Exception:
+ pass
+ finally:
+ if owns_sim_log:
+ sim_log.close()
self._running.pop(simulation_id, None)
self._paused.pop(simulation_id, None)
self._speed.pop(simulation_id, None)
- self._total_actions.pop(simulation_id, None)
- self._last_narrative.pop(simulation_id, None)
- self._enriched_agents.pop(simulation_id, None)
- self._enrichment_ready.pop(simulation_id, None)
- self._last_active_round.pop(simulation_id, None)
+ self._total_actions.pop(simulation_id, None)
+ self._last_narrative.pop(simulation_id, None)
+ self._enriched_agents.pop(simulation_id, None)
+ self._enrichment_ready.pop(simulation_id, None)
+ self._last_active_round.pop(simulation_id, None)
self.tension.cleanup(simulation_id)
self.gossip.cleanup(simulation_id)
- if self.forecast:
- self.forecast.cleanup(simulation_id)
+ if self.forecast:
+ self.forecast.cleanup(simulation_id)
def _select_active_agents(
self,
@@ -758,7 +832,7 @@ class SimulationEngine:
return selected
- async def _batch_decisions(
+ async def _batch_decisions(
self,
active_agents: list[AgentPersona],
world_state: WorldState,
@@ -770,35 +844,86 @@ class SimulationEngine:
prompt = self._build_decision_prompt(agent, world_state, all_agents, simulation_id)
prompts.append(prompt)
- responses = await self.llm.generate_batch(prompts, json_mode=True, max_tokens=500)
+ # Decision payloads contain several narrative fields. A 500-token
+ # ceiling is easy to hit (especially for Chinese responses), which
+ # leaves a truncated JSON object and loses the whole decision. Give
+ # the model enough room while still keeping each round bounded.
+ responses = await self.llm.generate_batch(
+ prompts, json_mode=True, max_tokens=900, purpose="居民决策"
+ )
- results: list[tuple[AgentPersona, AgentDecision]] = []
- for agent, response in zip(active_agents, responses):
- data = parse_json(response)
- if not data:
- results.append((agent, AgentDecision()))
- continue
-
- action_str = data.get("action", "DO_NOTHING")
- try:
- action = ActionType(action_str)
- except ValueError:
- action = ActionType.DO_NOTHING
-
- decision = AgentDecision(
- feel=data.get("feel", ""),
- want=data.get("want", ""),
- fear=data.get("fear", ""),
- life_context=data.get("life_context") or "",
- past_echo=data.get("past_echo") or "",
- action=action,
- args=data.get("args", {}),
- speech=data.get("speech"),
- internal_thought=data.get("internal_thought", ""),
- belief_updates=data.get("belief_updates", []),
- memory_promotion=data.get("memory_promotion"),
- )
- results.append((agent, decision))
+ results: list[tuple[AgentPersona, AgentDecision]] = []
+
+ def text_value(value, default: str = "") -> str:
+ """Keep the internal decision model strict at the LLM boundary."""
+ if value is None:
+ return default
+ if isinstance(value, str):
+ return value
+ return str(value)
+
+ for agent, response in zip(active_agents, responses):
+ data = parse_json(response, purpose=f"居民决策(居民={agent.name},ID={agent.id})")
+ if not data:
+ results.append((agent, AgentDecision()))
+ continue
+
+ # A provider may return a syntactically valid JSON array even
+ # though the prompt asks for one decision object. Treat that as
+ # a malformed decision and let the simulation continue with a
+ # safe no-op instead of calling `.get()` on a list and aborting
+ # the entire engine run.
+ if not isinstance(data, dict):
+ logger.warning(
+ "居民决策返回的 JSON 不是对象(居民=%s,类型=%s),本轮按无行动处理",
+ agent.name,
+ type(data).__name__,
+ )
+ results.append((agent, AgentDecision()))
+ continue
+
+ action_str = data.get("action", "DO_NOTHING")
+ try:
+ action = ActionType(action_str)
+ except (ValueError, TypeError):
+ action = ActionType.DO_NOTHING
+
+ raw_beliefs = data.get("belief_updates", [])
+ if isinstance(raw_beliefs, list):
+ belief_updates = [text_value(item) for item in raw_beliefs if item is not None]
+ else:
+ belief_updates = []
+
+ raw_speech = data.get("speech")
+ speech = None if raw_speech is None else text_value(raw_speech)
+ raw_memory = data.get("memory_promotion")
+ memory_promotion = None if raw_memory is None else text_value(raw_memory)
+
+ try:
+ decision = AgentDecision(
+ # Models frequently use null for optional narrative
+ # answers. Convert those values here so one malformed
+ # field cannot terminate an otherwise valid simulation.
+ feel=text_value(data.get("feel")),
+ want=text_value(data.get("want")),
+ fear=text_value(data.get("fear")),
+ life_context=text_value(data.get("life_context")),
+ past_echo=text_value(data.get("past_echo")),
+ action=action,
+ args=data.get("args", {}) if isinstance(data.get("args", {}), dict) else {},
+ speech=speech,
+ internal_thought=text_value(data.get("internal_thought")),
+ belief_updates=belief_updates,
+ memory_promotion=memory_promotion,
+ )
+ except Exception as exc:
+ logger.warning(
+ "居民决策字段校验失败(居民=%s,错误=%s),本轮按无行动处理",
+ agent.name,
+ exc,
+ )
+ decision = AgentDecision()
+ results.append((agent, decision))
return results
@@ -1287,7 +1412,14 @@ class SimulationEngine:
for a in nearby[:5]
) or "no one"
- context_parts = [f"Nearby: {nearby_text}"]
+ context_parts = [f"Nearby: {nearby_text}"]
+ resource_text = ", ".join(
+ f"{name}={amount}" for name, amount in agent.resources.items()
+ ) or "none"
+ context_parts.append(
+ "YOUR AVAILABLE RESOURCES (use these exact keys in action args): "
+ + resource_text
+ )
if world_state.active_disputes:
context_parts.append(f"Active disputes: {'; '.join(world_state.active_disputes[:3])}")
open_proposals = [p for p in world_state.proposals if p.status == "open"]
@@ -2043,7 +2175,7 @@ class SimulationEngine:
)
return response.strip()
except Exception as e:
- logger.warning("Investigation failed (%s -> %s): %s", agent.name, target.name, e)
+ logger.warning("调查失败(%s -> %s):%s", agent.name, target.name, e)
return ""
async def _reflective_memory_pass(
@@ -2068,10 +2200,12 @@ class SimulationEngine:
)
prompts.append((system, user))
- responses = await self.llm.generate_batch(prompts, json_mode=True, max_tokens=400)
+ responses = await self.llm.generate_batch(
+ prompts, json_mode=True, max_tokens=400, purpose="居民记忆更新"
+ )
for agent, resp in zip(agents, responses):
- data = parse_json(resp)
+ data = parse_json(resp, purpose=f"居民记忆更新(居民={agent.name},ID={agent.id})")
if data:
if "core_memory" in data and isinstance(data["core_memory"], list):
agent.core_memory = [str(m) for m in data["core_memory"]][:10]
@@ -2122,8 +2256,11 @@ class SimulationEngine:
"Describe: 1) What physically happens 2) Resource changes if any.\n"
"Return JSON: {\"description\": \"...\", \"resource_changes\": {}}"
)
- response = await self.llm.generate(system=system, user=event_text, json_mode=True, max_tokens=300)
- data = parse_json(response)
+ response = await self.llm.generate(
+ system=system, user=event_text, json_mode=True,
+ max_tokens=300, purpose="外部事件解析",
+ )
+ data = parse_json(response, purpose="外部事件解析")
description = data.get("description", event_text)
@@ -2155,5 +2292,5 @@ class SimulationEngine:
else:
await self.store.save_report(simulation_id, report)
except Exception as e:
- logger.error("Report generation failed for %s: %s", simulation_id, e, exc_info=True)
+ logger.error("模拟 %s:报告生成失败:%s", simulation_id, e, exc_info=True)
await self.store.set_report_status(simulation_id, "failed", error=str(e))
diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py
index ff808f5..445ee12 100644
--- a/backend/app/services/llm.py
+++ b/backend/app/services/llm.py
@@ -1,165 +1,287 @@
-from __future__ import annotations
-import asyncio
-import json
-import logging
-import re
-import time
-
-from openai import AsyncOpenAI
-
-logger = logging.getLogger(__name__)
-
-
-class LLMClient:
- def __init__(self, api_key: str, base_url: str, model: str, max_concurrent: int = 10):
- self.client = AsyncOpenAI(api_key=api_key, base_url=base_url)
- self.model = model
- self.semaphore = asyncio.Semaphore(max_concurrent)
- self.total_tokens = 0
- self.total_calls = 0
- self._lock = asyncio.Lock()
-
- async def _track(self, usage):
- if usage:
- async with self._lock:
- self.total_tokens += usage.total_tokens or 0
- self.total_calls += 1
-
- async def generate(
- self,
- system: str,
- user: str,
- json_mode: bool = False,
- max_tokens: int = 1000,
- retries: int = 3,
- ) -> str:
- kwargs: dict = {
- "model": self.model,
- "messages": [
- {"role": "system", "content": system},
- {"role": "user", "content": user},
- ],
- "max_tokens": max_tokens,
- "temperature": 0.8,
- }
- if json_mode:
- kwargs["response_format"] = {"type": "json_object"}
-
- for attempt in range(retries):
- try:
- async with self.semaphore:
- response = await self.client.chat.completions.create(**kwargs)
- await self._track(response.usage)
- content = response.choices[0].message.content or ""
- if not content:
- finish = getattr(response.choices[0], 'finish_reason', 'unknown')
- logger.warning("LLM returned empty content (finish_reason=%s)", finish)
- return content.strip()
- except Exception as e:
- if attempt == retries - 1:
- logger.error("LLM call failed after %d retries: %s", retries, e)
- raise
- wait = 2 ** attempt
- logger.warning("LLM call failed (attempt %d), retrying in %ds: %s", attempt + 1, wait, e)
- await asyncio.sleep(wait)
- return ""
-
- async def generate_batch(
- self,
- prompts: list[tuple[str, str]],
- json_mode: bool = False,
- max_tokens: int = 1000,
- ) -> list[str]:
- tasks = [self.generate(system, user, json_mode=json_mode, max_tokens=max_tokens) for system, user in prompts]
- results = await asyncio.gather(*tasks, return_exceptions=True)
- return [r if isinstance(r, str) else "" for r in results]
-
- async def generate_light(self, system: str, user: str, max_tokens: int = 100) -> str:
- return await self.generate(system, user, json_mode=False, max_tokens=max_tokens)
-
- def get_stats(self) -> dict:
- return {
- "total_tokens": self.total_tokens,
- "total_calls": self.total_calls,
- }
-
-
-def parse_json(text: str) -> dict:
- text = text.strip()
-
- # Strip modern LLM "thinking" blocks (Anthropic, DeepSeek, etc.) that may
- # appear either before or around the JSON payload.
- text = re.sub(r".*?", "", text, flags=re.DOTALL)
-
- # Markdown code fence: ```json ... ``` or ``` ... ```
- if text.startswith("```"):
- match = re.search(r"```(?:json)?\s*\n?(.*?)```", text, re.DOTALL)
- if match:
- text = match.group(1).strip()
-
- # Try the whole text first.
- try:
- return json.loads(text)
- except json.JSONDecodeError:
- pass
-
- # Otherwise find the first balanced { ... } or [ ... ] substring that parses.
- def _balanced_at(start: int) -> int | None:
- open_ch, other_ch = ("{", "}") if text[start] == "{" else (("[", "]") if text[start] == "[" else (None, None))
- if open_ch is None:
- return None
- depth = 0
- in_str = False
- esc = False
- for j in range(start, len(text)):
- c = text[j]
- if in_str:
- if esc:
- esc = False
- elif c == "\\":
- esc = True
- elif c == '"':
- in_str = False
- continue
- if c == '"':
- in_str = True
- elif c == open_ch:
- depth += 1
- elif c == other_ch:
- depth -= 1
- if depth == 0:
- return j
- return None
-
- for opener in ("{", "["):
- i = 0
- while True:
- idx = text.find(opener, i)
- if idx == -1:
- break
- end = _balanced_at(idx)
- if end is not None:
- try:
- return json.loads(text[idx : end + 1])
- except json.JSONDecodeError:
- pass
- i = idx + 1
-
- # Last-ditch: trim from the end while we can find a parseable prefix.
- for i in range(len(text) - 1, -1, -1):
- if text[i] in "]}":
- try:
- return json.loads(text[: i + 1])
- except json.JSONDecodeError:
- continue
-
- open_braces = text.count("{") - text.count("}")
- open_brackets = text.count("[") - text.count("]")
- repaired = text + ("}" * max(0, open_braces)) + ("]" * max(0, open_brackets))
- try:
- return json.loads(repaired)
- except json.JSONDecodeError:
- pass
-
- logger.error("Failed to parse JSON from LLM response: %s...", text[:200])
- return {}
+from __future__ import annotations
+import asyncio
+import json
+import logging
+import re
+import time
+from typing import Any
+
+from openai import AsyncOpenAI
+
+logger = logging.getLogger(__name__)
+
+
+class LLMClient:
+ def __init__(self, api_key: str, base_url: str, model: str, max_concurrent: int = 10):
+ self.client = AsyncOpenAI(api_key=api_key, base_url=base_url)
+ self.model = model
+ self._is_minimax = "minimax" in (base_url or "").lower()
+ self.semaphore = asyncio.Semaphore(max_concurrent)
+ self.total_tokens = 0
+ self.total_calls = 0
+ self._lock = asyncio.Lock()
+ # Per-purpose call stats — useful when a sim stalls and you need to
+ # know which LLM call category is slow. Reset by the engine at the
+ # start of each sim if you want per-sim stats.
+ self._purpose_stats: dict[str, dict[str, float]] = {}
+
+ async def _track(self, usage):
+ if usage:
+ async with self._lock:
+ self.total_tokens += usage.total_tokens or 0
+ self.total_calls += 1
+
+ async def _record_purpose(self, purpose: str | None, elapsed: float, ok: bool):
+ if not purpose:
+ return
+ async with self._lock:
+ st = self._purpose_stats.setdefault(
+ purpose, {"calls": 0, "errors": 0, "total_ms": 0.0}
+ )
+ st["calls"] += 1
+ st["total_ms"] += elapsed * 1000
+ if not ok:
+ st["errors"] += 1
+
+ async def generate(
+ self,
+ system: str,
+ user: str,
+ json_mode: bool = False,
+ max_tokens: int = 4096,
+ retries: int = 3,
+ purpose: str | None = None,
+ ) -> str:
+ kwargs: dict = {
+ "model": self.model,
+ "messages": [
+ {"role": "system", "content": system},
+ {"role": "user", "content": user},
+ ],
+ "temperature": 0.5 if json_mode else 1.0,
+ }
+ if self._is_minimax:
+ # MiniMax M3 enables thinking by default. In structured-output
+ # calls that can consume the completion budget before a visible
+ # message.content is produced. MiniMax-specific fields must be
+ # sent through extra_body when using the OpenAI Python SDK.
+ kwargs["max_completion_tokens"] = max_tokens
+ kwargs["extra_body"] = {"thinking": {"type": "disabled"}}
+ else:
+ kwargs["max_tokens"] = max_tokens
+
+ if json_mode and not self._is_minimax:
+ # MiniMax's newer M-series models do not reliably implement the
+ # OpenAI json_object response_format. They still receive the
+ # explicit JSON schema in the prompt and are parsed defensively.
+ kwargs["response_format"] = {"type": "json_object"}
+
+ t0 = time.monotonic()
+ for attempt in range(retries):
+ try:
+ async with self.semaphore:
+ response = await self.client.chat.completions.create(**kwargs)
+ await self._track(response.usage)
+ if not response.choices:
+ logger.warning(
+ "LLM 返回空 choices(用途=%s,响应ID=%s,模型=%s)",
+ purpose or "-", getattr(response, "id", "-"), self.model,
+ )
+ content = ""
+ finish = "no_choices"
+ message = None
+ else:
+ choice = response.choices[0]
+ message = choice.message
+ content = getattr(message, "content", None) or ""
+ finish = getattr(choice, "finish_reason", "unknown")
+
+ # A few OpenAI-compatible providers return content parts
+ # instead of a plain string. Normalize text parts here.
+ if isinstance(content, list):
+ content = "".join(
+ part.get("text", "") if isinstance(part, dict)
+ else getattr(part, "text", "") or ""
+ for part in content
+ )
+
+ if not content:
+ reasoning = getattr(message, "reasoning_content", None) if message else None
+ reasoning_details = getattr(message, "reasoning_details", None) if message else None
+ refusal = getattr(message, "refusal", None) if message else None
+ usage = getattr(response, "usage", None)
+ logger.warning(
+ "LLM 返回空内容(用途=%s,结束原因=%s,完成令牌=%s,推理内容长度=%s,推理详情=%s,拒答=%s)",
+ purpose or "-",
+ finish,
+ getattr(usage, "completion_tokens", "?"),
+ len(reasoning or ""),
+ bool(reasoning_details),
+ bool(refusal),
+ )
+ if attempt < retries - 1:
+ wait = 2 ** attempt
+ logger.warning(
+ "LLM 返回空内容,将在%d秒后重试(用途=%s,第%d/%d次)",
+ wait, purpose or "-", attempt + 1, retries,
+ )
+ await asyncio.sleep(wait)
+ continue
+ await self._record_purpose(purpose, time.monotonic() - t0, ok=False)
+ return ""
+ await self._record_purpose(purpose, time.monotonic() - t0, ok=True)
+ if purpose:
+ logger.debug(
+ "LLM 调用成功(用途=%s,令牌数=%s,耗时=%.2f秒)",
+ purpose,
+ getattr(response.usage, "total_tokens", "?"),
+ time.monotonic() - t0,
+ )
+ return content.strip()
+ except Exception as e:
+ if attempt == retries - 1:
+ await self._record_purpose(purpose, time.monotonic() - t0, ok=False)
+ logger.error(
+ "LLM 调用重试 %d 次后仍失败(用途=%s,耗时=%.2f秒):%s",
+ retries, purpose or "-", time.monotonic() - t0, e,
+ )
+ raise
+ wait = 2 ** attempt
+ logger.warning(
+ "LLM 调用失败(用途=%s,第%d次尝试,%d秒后重试,已耗时=%.2f秒):%s",
+ purpose or "-", attempt + 1, wait, time.monotonic() - t0, e,
+ )
+ await asyncio.sleep(wait)
+ return ""
+
+ async def generate_batch(
+ self,
+ prompts: list[tuple[str, str]],
+ json_mode: bool = False,
+ max_tokens: int = 1000,
+ purpose: str | None = None,
+ sub_purpose: str | None = None,
+ ) -> list[str]:
+ # `purpose` is the batch label (e.g. "decision"); `sub_purpose` lets
+ # us split stats by an inner dimension (e.g. "active/react").
+ tasks = [
+ self.generate(
+ system, user,
+ json_mode=json_mode, max_tokens=max_tokens,
+ purpose=sub_purpose or purpose,
+ )
+ for system, user in prompts
+ ]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+ return [r if isinstance(r, str) else "" for r in results]
+
+ async def generate_light(self, system: str, user: str, max_tokens: int = 100) -> str:
+ return await self.generate(system, user, json_mode=False, max_tokens=max_tokens)
+
+ def get_stats(self) -> dict:
+ return {
+ "total_tokens": self.total_tokens,
+ "total_calls": self.total_calls,
+ "by_purpose": dict(self._purpose_stats),
+ }
+
+
+def parse_json(text: str, purpose: str | None = None) -> Any:
+ """Parse a model response that was requested as JSON.
+
+ Providers occasionally return prose, truncated JSON, or a markdown code
+ block even when ``response_format=json_object`` was requested. Keep the
+ permissive recovery logic, but make a final failure actionable: include
+ the call purpose, response length, and a bounded raw preview in the
+ simulation log.
+ """
+ text = (text or "").strip()
+ label = purpose or "未标注用途"
+
+ if not text:
+ logger.error("JSON 解析失败(用途=%s):LLM 返回内容为空", label)
+ return {}
+
+ # Strip modern LLM "thinking" blocks (Anthropic, DeepSeek, etc.) that may
+ # appear either before or around the JSON payload.
+ text = re.sub(r".*?", "", text, flags=re.DOTALL)
+
+ # Markdown code fence: ```json ... ``` or ``` ... ```
+ if text.startswith("```"):
+ match = re.search(r"```(?:json)?\s*\n?(.*?)```", text, re.DOTALL)
+ if match:
+ text = match.group(1).strip()
+
+ # Try the whole text first.
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ pass
+
+ # Otherwise find the first balanced { ... } or [ ... ] substring that parses.
+ def _balanced_at(start: int) -> int | None:
+ open_ch, other_ch = ("{", "}") if text[start] == "{" else (("[", "]") if text[start] == "[" else (None, None))
+ if open_ch is None:
+ return None
+ depth = 0
+ in_str = False
+ esc = False
+ for j in range(start, len(text)):
+ c = text[j]
+ if in_str:
+ if esc:
+ esc = False
+ elif c == "\\":
+ esc = True
+ elif c == '"':
+ in_str = False
+ continue
+ if c == '"':
+ in_str = True
+ elif c == open_ch:
+ depth += 1
+ elif c == other_ch:
+ depth -= 1
+ if depth == 0:
+ return j
+ return None
+
+ for opener in ("{", "["):
+ i = 0
+ while True:
+ idx = text.find(opener, i)
+ if idx == -1:
+ break
+ end = _balanced_at(idx)
+ if end is not None:
+ try:
+ return json.loads(text[idx : end + 1])
+ except json.JSONDecodeError:
+ pass
+ i = idx + 1
+
+ # Last-ditch: trim from the end while we can find a parseable prefix.
+ for i in range(len(text) - 1, -1, -1):
+ if text[i] in "]}":
+ try:
+ return json.loads(text[: i + 1])
+ except json.JSONDecodeError:
+ continue
+
+ open_braces = text.count("{") - text.count("}")
+ open_brackets = text.count("[") - text.count("]")
+ repaired = text + ("}" * max(0, open_braces)) + ("]" * max(0, open_brackets))
+ try:
+ return json.loads(repaired)
+ except json.JSONDecodeError:
+ pass
+
+ preview = text[:1000].replace("\r", "\\r").replace("\n", "\\n")
+ logger.error(
+ "JSON 解析失败(用途=%s,返回长度=%d):内容不是有效 JSON 或已被截断;原始返回片段=%s%s",
+ label,
+ len(text),
+ preview,
+ "……" if len(text) > 1000 else "",
+ )
+ return {}
diff --git a/backend/app/services/narrator.py b/backend/app/services/narrator.py
index ade6089..ba4ffc9 100644
--- a/backend/app/services/narrator.py
+++ b/backend/app/services/narrator.py
@@ -306,24 +306,24 @@ class Narrator:
try:
forecast_analysis = forecast.analyze(metrics_history)
except Exception as e:
- logger.debug("Forecast analysis failed: %s", e)
+ logger.debug("预测分析失败:%s", e)
try:
causal_links = forecast.discover_causality(simulation_id)
causal_map = ReportAnalyzer.format_causal_links(causal_links)
except Exception as e:
- logger.debug("Causal discovery failed: %s", e)
+ logger.debug("因果关系分析失败:%s", e)
try:
counterfactual_results = forecast.compute_counterfactuals(simulation_id)
counterfactual_data = ReportAnalyzer.format_counterfactuals(counterfactual_results)
except Exception as e:
- logger.debug("Counterfactual computation failed: %s", e)
+ logger.debug("反事实计算失败:%s", e)
try:
from app.services.forecast import _SimState
sim_state = forecast._sims.get(simulation_id)
if sim_state and sim_state.coherence_scores:
coherence_data = ReportAnalyzer.format_coherence_scores(sim_state.coherence_scores)
except Exception as e:
- logger.debug("Coherence formatting failed: %s", e)
+ logger.debug("一致性结果格式化失败:%s", e)
# --- Pass 2: Metric interpretation + insights ---
metrics_summary = "\n".join(
@@ -363,8 +363,8 @@ class Narrator:
+ (f"\n\nCausal analysis:\n{causal_summary}" if causal_summary else "")
)
- response_p2 = await self.llm.generate(system=system_p2, user=user_p2, json_mode=True, max_tokens=2000)
- pass2 = parse_json(response_p2)
+ response_p2 = await self.llm.generate(system=system_p2, user=user_p2, json_mode=True, max_tokens=2000, purpose="报告指标分析")
+ pass2 = parse_json(response_p2, purpose="报告指标分析")
explanations = pass2.get("metric_explanations", {})
scorecard = []
@@ -397,8 +397,8 @@ class Narrator:
) + f"\n\n{tail}"
user_p3 = f"Segment data:\n{segment_input}"
- response_p3 = await self.llm.generate(system=system_p3, user=user_p3, json_mode=True, max_tokens=1500)
- pass3 = parse_json(response_p3)
+ response_p3 = await self.llm.generate(system=system_p3, user=user_p3, json_mode=True, max_tokens=1500, purpose="报告分群分析")
+ pass3 = parse_json(response_p3, purpose="报告分群分析")
llm_segments = {s["name"]: s for s in pass3.get("segments", [])}
segments_final = []
@@ -437,8 +437,8 @@ class Narrator:
rules="; ".join(blueprint.rules),
) + f"\n\n{tail}"
- response_p4 = await self.llm.generate(system=system_p4, user=synthesis_context, json_mode=True, max_tokens=1500)
- pass4 = parse_json(response_p4)
+ response_p4 = await self.llm.generate(system=system_p4, user=synthesis_context, json_mode=True, max_tokens=1500, purpose="报告行动建议")
+ pass4 = parse_json(response_p4, purpose="报告行动建议")
# --- Assemble final report ---
key_moments = []
diff --git a/backend/app/services/resolver.py b/backend/app/services/resolver.py
index 5478e6e..6fc4266 100644
--- a/backend/app/services/resolver.py
+++ b/backend/app/services/resolver.py
@@ -11,7 +11,29 @@ from app.constants import TIMES_OF_DAY, normalize_emotional_state
logger = logging.getLogger(__name__)
-class ActionResolver:
+class ActionResolver:
+ @staticmethod
+ def _resolve_resource_name(raw_name, available: set[str]) -> str | None:
+ """Return a valid resource key or None for an LLM-invented key.
+
+ Action arguments are generated text, so a model may describe a
+ resource (for example, "my own labor and what scraps I have") instead
+ of using one of the machine-readable keys in the world blueprint.
+ Resource mutations must never use that unchecked value as a dict key.
+ """
+ if not isinstance(raw_name, str):
+ return None
+ value = raw_name.strip()
+ if not value:
+ return None
+ if value in available:
+ return value
+ lowered = value.lower()
+ for resource in available:
+ if str(resource).lower() == lowered:
+ return resource
+ return None
+
@staticmethod
def _resolve_agent_id(ref, agent_map: dict[int, AgentPersona]) -> int | None:
if ref is None:
@@ -351,15 +373,33 @@ class ActionResolver:
self._strengthen_social_edge(other, agent.id, 0.05, 0.1)
rel_changes[str(other.id)] = "impressed"
- elif at == ActionType.BUILD:
- cost_resource = action_args.get("resource", "goods")
- try:
- cost_amount = int(action_args.get("cost", 20))
- except (ValueError, TypeError):
- cost_amount = 20
- if agent.resources.get(cost_resource, 0) >= cost_amount:
- agent.resources[cost_resource] -= cost_amount
- world_changes["built"] = action_args.get("what", "something")
+ elif at == ActionType.BUILD:
+ # Only blueprint resources are valid action keys. Persona data
+ # can also contain arbitrary model-generated labels, so accepting
+ # every key currently present in ``agent.resources`` would let an
+ # invented phrase become part of the simulation vocabulary.
+ available_resources = {
+ str(resource) for resource in world_state.blueprint.resources
+ }
+ raw_resource = action_args.get("resource", "goods")
+ cost_resource = self._resolve_resource_name(raw_resource, available_resources)
+ try:
+ cost_amount = int(action_args.get("cost", 20))
+ except (ValueError, TypeError):
+ cost_amount = 20
+ if cost_amount <= 0:
+ cost_amount = 20
+
+ if cost_resource is None:
+ logger.warning(
+ "居民 %s 的 BUILD 行动使用了无效资源 %r,已忽略该行动",
+ agent.name,
+ raw_resource,
+ )
+ world_changes["build_failed"] = "invalid_resource"
+ elif agent.resources.get(cost_resource, 0) >= cost_amount:
+ agent.resources[cost_resource] -= cost_amount
+ world_changes["built"] = action_args.get("what", "something")
elif at == ActionType.OBSERVE:
agent.resources["knowledge"] = agent.resources.get("knowledge", 0) + 3
diff --git a/backend/app/services/sim_logger.py b/backend/app/services/sim_logger.py
index 2f7e763..f600d24 100644
--- a/backend/app/services/sim_logger.py
+++ b/backend/app/services/sim_logger.py
@@ -1,9 +1,8 @@
from __future__ import annotations
-import json
-import logging
-import os
-from datetime import datetime, timezone
-from pathlib import Path
+import json
+import logging
+from datetime import datetime, timezone
+from pathlib import Path
from typing import Any
from app.models.agent import AgentPersona
@@ -12,28 +11,52 @@ from app.models.world import WorldMetrics
logger = logging.getLogger(__name__)
-LOGS_DIR = Path(__file__).resolve().parent.parent.parent / "logs"
-
-
-class SimulationLogger:
- def __init__(self, sim_id: str):
- self._sim_id = sim_id
- LOGS_DIR.mkdir(parents=True, exist_ok=True)
- self._path = LOGS_DIR / f"{sim_id}.jsonl"
- try:
- self._fh = open(self._path, "a", encoding="utf-8")
- except OSError as exc:
- logger.warning("SimulationLogger: cannot open %s: %s", self._path, exc)
- self._fh = None
+BACKEND_LOGS_DIR = Path(__file__).resolve().parent.parent.parent / "logs"
+PROJECT_LOGS_DIR = Path(__file__).resolve().parents[3] / "logs"
+
+
+class SimulationLogger:
+ def __init__(self, sim_id: str):
+ self._sim_id = sim_id
+ # Keep the structured per-simulation log for programmatic inspection,
+ # and create a human-readable text log in the project root at the
+ # moment the user clicks "Create World".
+ BACKEND_LOGS_DIR.mkdir(parents=True, exist_ok=True)
+ PROJECT_LOGS_DIR.mkdir(parents=True, exist_ok=True)
+ self._created_at = datetime.now().astimezone()
+ self._path = BACKEND_LOGS_DIR / f"{sim_id}.jsonl"
+ self.text_path = PROJECT_LOGS_DIR / (
+ f"logs_{self._created_at.strftime('%Y%m%d_%H%M%S_%f')}.txt"
+ )
+ try:
+ self._fh = open(self._path, "a", encoding="utf-8")
+ except OSError as exc:
+ logger.warning("SimulationLogger: cannot open %s: %s", self._path, exc)
+ self._fh = None
+ try:
+ self._text_fh = open(self.text_path, "a", encoding="utf-8")
+ self._text_fh.write(
+ f"[{self._created_at.isoformat()}] simulation_started "
+ f"sim_id={self._sim_id}\n"
+ )
+ self._text_fh.flush()
+ except OSError as exc:
+ logger.warning("SimulationLogger: cannot open %s: %s", self.text_path, exc)
+ self._text_fh = None
def _write(self, record: dict[str, Any]):
if not self._fh:
return
- try:
- self._fh.write(json.dumps(record, default=str) + "\n")
- self._fh.flush()
- except Exception as exc:
- logger.debug("SimulationLogger write error: %s", exc)
+ try:
+ serialized = json.dumps(record, ensure_ascii=False, default=str)
+ self._fh.write(serialized + "\n")
+ self._fh.flush()
+ if self._text_fh:
+ ts = record.get("ts", datetime.now().astimezone().isoformat())
+ self._text_fh.write(f"[{ts}] {serialized}\n")
+ self._text_fh.flush()
+ except Exception as exc:
+ logger.debug("SimulationLogger write error: %s", exc)
def log_round(
self,
@@ -150,6 +173,16 @@ class SimulationLogger:
if self._fh:
try:
self._fh.close()
- except Exception:
- pass
- self._fh = None
+ except Exception:
+ pass
+ self._fh = None
+ if self._text_fh:
+ try:
+ self._text_fh.write(
+ f"[{datetime.now().astimezone().isoformat()}] simulation_log_closed\n"
+ )
+ self._text_fh.flush()
+ self._text_fh.close()
+ except Exception:
+ pass
+ self._text_fh = None
diff --git a/backend/app/services/simulation_logging.py b/backend/app/services/simulation_logging.py
new file mode 100644
index 0000000..ffac934
--- /dev/null
+++ b/backend/app/services/simulation_logging.py
@@ -0,0 +1,82 @@
+"""Per-simulation capture of the same logs shown in the backend terminal."""
+
+from __future__ import annotations
+
+import logging
+import threading
+from contextvars import ContextVar
+from pathlib import Path
+
+
+# Context variables are inherited by asyncio tasks, including the concurrent
+# LLM calls created during a simulation. This keeps concurrent simulations from
+# writing each other's records to the same file.
+CURRENT_SIMULATION_ID: ContextVar[str | None] = ContextVar(
+ "current_simulation_id", default=None
+)
+
+PROJECT_LOGS_DIR = Path(__file__).resolve().parents[3] / "logs"
+
+
+class ChineseSimulationFormatter(logging.Formatter):
+ """Readable Chinese metadata while preserving the original message."""
+
+ LEVELS = {
+ "DEBUG": "调试",
+ "INFO": "信息",
+ "WARNING": "警告",
+ "ERROR": "错误",
+ "CRITICAL": "严重错误",
+ }
+
+ def format(self, record: logging.LogRecord) -> str:
+ timestamp = f"{self.formatTime(record, '%Y-%m-%d %H:%M:%S')},{int(record.msecs):03d}"
+ level = self.LEVELS.get(record.levelname, record.levelname)
+ line = f"{timestamp} [模拟日志] [{level}] [{record.name}] {record.getMessage()}"
+ if record.exc_info:
+ line += "\n" + self.formatException(record.exc_info)
+ return line
+
+
+class SimulationLogHandler(logging.Handler):
+ """Write terminal-formatted records to ``logs/.log``."""
+
+ def __init__(self) -> None:
+ super().__init__(level=logging.INFO)
+ self._write_lock = threading.Lock()
+
+ def emit(self, record: logging.LogRecord) -> None:
+ simulation_id = CURRENT_SIMULATION_ID.get()
+ if not simulation_id:
+ return
+
+ try:
+ PROJECT_LOGS_DIR.mkdir(parents=True, exist_ok=True)
+ path = PROJECT_LOGS_DIR / f"{simulation_id}.log"
+ line = self.format(record)
+ with self._write_lock:
+ with path.open("a", encoding="utf-8") as handle:
+ handle.write(line + "\n")
+ except Exception:
+ # Logging must never break a simulation if the disk is unavailable.
+ self.handleError(record)
+
+
+def install_simulation_log_handler() -> None:
+ """Install the capture handler once on the process root logger."""
+ root = logging.getLogger()
+ if any(isinstance(handler, SimulationLogHandler) for handler in root.handlers):
+ return
+
+ handler = SimulationLogHandler()
+ handler.setFormatter(ChineseSimulationFormatter())
+ root.addHandler(handler)
+
+
+def bind_simulation(simulation_id: str):
+ """Bind a simulation ID to the current asyncio task."""
+ return CURRENT_SIMULATION_ID.set(simulation_id)
+
+
+def unbind_simulation(token) -> None:
+ CURRENT_SIMULATION_ID.reset(token)
diff --git a/backend/app/services/tension.py b/backend/app/services/tension.py
index d949f68..bdf8875 100644
--- a/backend/app/services/tension.py
+++ b/backend/app/services/tension.py
@@ -370,7 +370,7 @@ class TensionEngine:
)
response = await self.llm.generate(system=system, user=user, json_mode=True, max_tokens=300)
- data = parse_json(response)
+ data = parse_json(response, purpose="紧张事件生成")
event_desc = data.get("event", "An unexpected disruption shook the community.")
@@ -422,7 +422,7 @@ class TensionEngine:
total=len(agents),
)
response = await self.llm.generate(system=system, user="Generate a splinter ideology.", json_mode=True, max_tokens=200)
- data = parse_json(response)
+ data = parse_json(response, purpose="分裂派系意识形态生成")
splinter_belief = data.get("splinter_belief", f"The {faction_name} has lost its way")
diff --git a/backend/app/services/world_generator.py b/backend/app/services/world_generator.py
index 1759432..392f374 100644
--- a/backend/app/services/world_generator.py
+++ b/backend/app/services/world_generator.py
@@ -72,23 +72,24 @@ active_agents_per_round_max should be about {max(3, int(population * 0.4))}"""
if proposed_change:
user_prompt += f'\n\nPROPOSED CHANGE being introduced into this world:\n"{proposed_change}"'
- response = await self.llm.generate(
- system=system,
- user=user_prompt,
- json_mode=True,
- max_tokens=3000,
- )
+ response = await self.llm.generate(
+ system=system,
+ user=user_prompt,
+ json_mode=True,
+ max_tokens=3000,
+ purpose="world_gen",
+ )
- data = parse_json(response)
+ data = parse_json(response, purpose="世界生成")
if not data:
- logger.error("World generation produced empty result, using fallback")
+ logger.error("世界生成返回空结果,将使用备用世界")
return self._fallback_blueprint(rules_text, population, duration_days, proposed_change)
# Some LLMs occasionally return a list (e.g. citizens array) instead of
# a blueprint object. Wrap it defensively so the rest of the code keeps
# working.
if isinstance(data, list):
- logger.warning("World blueprint came back as a list, treating as fallback")
+ logger.warning("世界蓝图返回了列表,将使用备用世界")
return self._fallback_blueprint(rules_text, population, duration_days, proposed_change)
try:
@@ -135,7 +136,7 @@ active_agents_per_round_max should be about {max(3, int(population * 0.4))}"""
time_config=time_config,
)
except Exception as e:
- logger.error("Failed to parse world blueprint: %s", e)
+ logger.error("世界蓝图解析失败:%s", e)
return self._fallback_blueprint(rules_text, population, duration_days, proposed_change)
@staticmethod
@@ -209,4 +210,4 @@ active_agents_per_round_max should be about {max(3, int(population * 0.4))}"""
active_agents_per_round_min=3,
active_agents_per_round_max=max(3, int(population * 0.4)),
),
- )
\ No newline at end of file
+ )
diff --git a/custom_simulation/国内社会现象.txt b/custom_simulation/国内社会现象.txt
new file mode 100644
index 0000000..514f320
--- /dev/null
+++ b/custom_simulation/国内社会现象.txt
@@ -0,0 +1,61 @@
+## 一、推演背景
+
+基于真实数据构建的中国社会经济沙盒系统。该系统的核心任务是:**在失业率、生育率与政策干预三者之间的动态博弈中,模拟不同政策组合的传导效应与长期均衡结果。**
+
+当前系统初始化时间为2026年初,初始状态锚定以下事实基线:
+
+**就业基线**:2025年全国城镇调查失业率全年均值为5.2%,城镇新增就业1267万人,失业保险降费率减收失业保险费1872亿元,发放稳岗返还资金336亿元。全国基本养老、失业、工伤保险参保人数分别为10.76亿人、2.49亿人、3.05亿人。重点群体方面,2025届高校毕业生就业攻坚行动持续推进,脱贫人口务工规模保持在3000万人以上。
+
+**人口基线**:2025年末全国人口140489万人,全年出生人口792万人,死亡人口1131万人,人口自然增长率为-2.41‰,人口总量同比减少339万人。16—59岁劳动年龄人口85136万人,60岁及以上人口32338万人(占23.0%),65岁以上22365万人(占15.9%)。城镇化率67.89%。
+
+**政策基线**:生育支持体系已形成“经济支持、时间支持、服务支持、文化支持”四柱支撑结构。经济支持方面,自2025年1月1日起对3周岁以下婴幼儿发放每孩每年3600元育儿补贴,全国各级财政安排约1000亿元,其中中央财政904亿元,已惠及3300万婴幼儿家庭。时间支持方面,产假普遍延长至158天,育儿假、护理假在各省不同程度落地。服务支持方面,全国托育服务机构12.6万家,托位总数666万个,千人口托位数4.73个。就业政策方面,“十五五”规划建议明确“深入实施就业优先战略”,强化产业与就业协同,培育新职业新岗位,支持企业稳岗扩岗。
+
+## 二、推演目标
+
+你的核心任务不是给出单一预测,而是**探索三条政策路径的差异化后果**:
+
+**路径A:托育基建优先。** 假设“十五五”期间将托育服务补助示范试点全面铺开,普惠托位从666万提升至1200万,中央与地方按比例分担运营成本。追踪其对:女性劳动参与率→家庭二孩意愿→城镇调查失业率(短期托育行业吸纳就业 vs 长期女性重返职场挤压岗位)的连锁效应。
+
+**路径B:现金激励优先。** 假设育儿补贴从3600元/孩/年大幅提升至7200元,并叠加二孩、三孩累进补贴。追踪其对:生育决策弹性→家庭可支配收入→消费与储蓄行为→劳动力供给意愿(“补贴依赖”还是“释放消费带动就业”)的净效应。
+
+**路径C:就业保障优先。** 假设在现有稳岗返还基础上,将“稳岗扩容提质行动”升级为对吸纳育龄女性企业的社保减免与税收抵扣专项。追踪其对:企业用工成本→女性就业歧视的减弱或强化→生育后职业连续性→生育率的间接支撑效果。
+
+每条路径需输出:**3年短周期冲击、5年中周期调整、10年长周期均衡**的三阶段推演报告,包含关键变量轨迹、拐点预警、以及路径间的交叉影响矩阵。
+
+## 三、核心约束条件
+
+推演必须尊重以下结构性约束,不得假设其可在短期内被政策打破:
+
+**人口惯性约束**:即使生育率立即反弹,人口负增长惯性仍将持续15—20年,因为育龄妇女基数已因过去三十年的低生育率而收缩。2025年出生人口792万,仅为2016年(1786万)的44%,这意味着20年后进入劳动市场的队列规模已基本锁定。政策只能在“缩小的底座”上做边际改善。
+
+**财政可持续性约束**:生育补贴、托育补助、稳岗返还均依赖财政支出。当前三项社保基金累计结余9.85万亿元,但60岁以上人口每年净增超1300万,养老金支出刚性增长将挤压其他政策空间。任何推演必须包含“补贴退坡”或“财政压力传导”的情景测试。
+
+**企业行为约束**:生育假期延长与女性用工成本上升之间存在张力。委员建议“由政府或专项基金统一兜底保障”正是对这一张力的承认。推演中需考虑:若企业感知育龄女性用工成本上升20%—30%,其在招聘中的隐性偏好变化,以及这是否会部分抵消托育政策对女性劳动参与的正向激励。
+
+**政策时滞约束**:育儿补贴的生育效应通常滞后12—24个月显现;托育基建从投资到运营需18—36个月;就业培训转化为实际就业需6—18个月。推演中政策发布与效果观测之间必须保留合理时滞,不得出现“政策一出、指标立变”的失真模拟。
+
+## 四、关键观测变量
+
+推演过程中需持续追踪以下变量的动态变化,并在推演报告中标注其偏离基线的幅度:
+
+**就业侧**:城镇调查失业率(总体/青年/女性分项)、城镇新增就业人数、稳岗返还资金支出效率(每亿元稳岗资金对应的就业保留数)、灵活就业占比、失业保险参保人数变化。
+
+**生育侧**:年出生人口数、总和生育率估计值(或出生率代理指标)、二孩及以上占比、育儿补贴申领率与实际到账周期、托位使用率(入托率/托位总数)、生育津贴直发覆盖率。
+
+**交叉侧**:女性劳动参与率、育龄女性就业率(25—39岁)、家庭负债收入比变化、社会消费品零售总额中的母婴品类增速、学前教育在园幼儿数(3年后验证生育政策效果)。
+
+**政策侧**:财政用于生育支持与就业稳岗的总支出占GDP比重、企业社保缴费率实际负担、托育服务价格指数、生育假期实际落实率(对比法定标准)。
+
+## 五、推演输出格式
+
+请以**结构化推演报告**形式输出,包含以下模块:
+
+**第一,状态快照(T+0)。** 确认当前系统各关键变量的基线值,标注数据来源与置信度。
+
+**第二,政策冲击传导图。** 以文字描述每条路径的因果链条,标注每一环的时滞与放大/衰减系数。例如:育儿补贴翻倍→家庭月可支配收入增加300元→消费倾向0.6→母婴零售岗位需求增加X个→城镇调查失业率下降Y个百分点(时滞12—18个月)。
+
+**第三,三阶段情景矩阵。** 对每条路径在T+3、T+5、T+10时点的关键指标进行区间估计,区分“基准情景”“乐观情景(政策执行效率提升30%)”“悲观情景(财政压力触发补贴退坡20%)”。
+
+**第四,路径交叉影响分析。** 若同时实施两条路径(如A+C或B+C),是否存在协同效应或挤出效应?例如:托育基建与就业保障同时推进,是否比单一政策更有效?
+
+**第五,拐点预警与决策建议。** 标注推演过程中出现的关键拐点(如女性劳动参与率开始下降、托位空置率超过30%、财政生育支持支出占GDP超过0.5%),并给出针对agent决策者的策略建议:在什么条件下应切换政策重心,在什么阈值上应启动应急调整。
diff --git a/deploy.sh b/deploy.sh
index e0c02c8..ccadc72 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -1,58 +1,58 @@
-#!/usr/bin/env bash
-# MiroSociety 一键部署脚本 (依赖安装)
-# - 用 uv 创建后端 venv 并安装依赖
-# - 用 npm 安装前端依赖
-# 可重复执行,已安装会跳过。
-
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-BACKEND_DIR="$SCRIPT_DIR/backend"
-FRONTEND_DIR="$SCRIPT_DIR/frontend"
-cd "$SCRIPT_DIR"
-
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-RED='\033[0;31m'
-NC='\033[0m'
-
-log() { echo -e "${GREEN}[deploy.sh]${NC} $*"; }
-warn() { echo -e "${YELLOW}[deploy.sh]${NC} $*"; }
-err() { echo -e "${RED}[deploy.sh]${NC} $*" >&2; }
-
-# --- 检查命令依赖 ---
-for cmd in uv npm node; do
- if ! command -v "$cmd" >/dev/null 2>&1; then
- err "缺少依赖命令: $cmd"
- exit 1
- fi
-done
-
-VENV_DIR="$BACKEND_DIR/.venv"
-VENV_PY="$VENV_DIR/bin/python3"
-
-# --- 后端: uv venv + pip install ---
-log "检查后端环境..."
-if [[ ! -x "$VENV_PY" ]]; then
- warn "未检测到 $VENV_DIR,使用 uv 创建 venv..."
- (cd "$BACKEND_DIR" && uv venv)
-fi
-
-log "安装/更新后端依赖 (uv pip install -e .)..."
-(cd "$BACKEND_DIR" && uv pip install -e .)
-
-# --- 前端: npm install ---
-log "检查前端环境..."
-if [[ ! -d "$FRONTEND_DIR/node_modules" ]]; then
- warn "未检测到 $FRONTEND_DIR/node_modules,开始 npm install..."
- (cd "$FRONTEND_DIR" && npm install)
-else
- log "$FRONTEND_DIR/node_modules 已存在,跳过 (如需重装请删除该目录)"
-fi
-
-echo
-log "部署完成 ✓"
-echo -e " ${GREEN}后端 venv:${NC} $VENV_DIR"
-echo -e " ${GREEN}前端依赖:${NC} frontend/node_modules"
-echo
+#!/usr/bin/env bash
+# MiroSociety 一键部署脚本 (依赖安装)
+# - 用 uv 创建后端 venv 并安装依赖
+# - 用 npm 安装前端依赖
+# 可重复执行,已安装会跳过。
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+BACKEND_DIR="$SCRIPT_DIR/backend"
+FRONTEND_DIR="$SCRIPT_DIR/frontend"
+cd "$SCRIPT_DIR"
+
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m'
+
+log() { echo -e "${GREEN}[deploy.sh]${NC} $*"; }
+warn() { echo -e "${YELLOW}[deploy.sh]${NC} $*"; }
+err() { echo -e "${RED}[deploy.sh]${NC} $*" >&2; }
+
+# --- 检查命令依赖 ---
+for cmd in uv npm node; do
+ if ! command -v "$cmd" >/dev/null 2>&1; then
+ err "缺少依赖命令: $cmd"
+ exit 1
+ fi
+done
+
+VENV_DIR="$BACKEND_DIR/.venv"
+VENV_PY="$VENV_DIR/bin/python3"
+
+# --- 后端: uv venv + pip install ---
+log "检查后端环境..."
+if [[ ! -x "$VENV_PY" ]]; then
+ warn "未检测到可用的 $VENV_DIR,使用 uv 重建 venv..."
+ (cd "$BACKEND_DIR" && uv venv --clear)
+fi
+
+log "安装/更新后端依赖 (uv pip install -e .)..."
+(cd "$BACKEND_DIR" && uv pip install -e .)
+
+# --- 前端: npm install ---
+log "检查前端环境..."
+if [[ ! -d "$FRONTEND_DIR/node_modules" ]]; then
+ warn "未检测到 $FRONTEND_DIR/node_modules,开始 npm install..."
+ (cd "$FRONTEND_DIR" && npm install)
+else
+ log "$FRONTEND_DIR/node_modules 已存在,跳过 (如需重装请删除该目录)"
+fi
+
+echo
+log "部署完成 ✓"
+echo -e " ${GREEN}后端 venv:${NC} $VENV_DIR"
+echo -e " ${GREEN}前端依赖:${NC} frontend/node_modules"
+echo
log "现在可以执行 ./start.sh 启动服务"
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 8bd12f6..8ef6b73 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,9 +1,23 @@
-services:
- app:
- build: .
- ports:
- - "8000:8000"
- env_file: .env
- volumes:
- - ./data:/app/data
- restart: unless-stopped
+services:
+ app:
+ build: .
+ ports:
+ - "${APP_PORT:-8000}:8000"
+ env_file: .env
+ environment:
+ HOST: 0.0.0.0
+ PORT: 8000
+ DATABASE_DIR: /app/data
+ volumes:
+ - ./data:/app/data
+ - ./logs:/app/logs
+ - ./backend/logs:/app/backend/logs
+ - ./custom_simulation:/app/custom_simulation
+ init: true
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)"]
+ interval: 30s
+ timeout: 10s
+ start_period: 20s
+ retries: 3
+ restart: unless-stopped
diff --git a/docs/问题记录-2026-09-27-硬编码与搜索替换.md b/docs/问题记录-2026-09-27-硬编码与搜索替换.md
new file mode 100644
index 0000000..8c340ca
--- /dev/null
+++ b/docs/问题记录-2026-09-27-硬编码与搜索替换.md
@@ -0,0 +1,114 @@
+# 问题记录:2026-09-27
+
+> 状态:待处理(明天再动)
+> 记录人:项目相关方
+> 本文件只记问题,不附解决方案。
+
+---
+
+## 一、背景
+
+今天在排查项目时发现两个独立但都和"千人模拟"业务相关的问题。先记下来,明天再决定怎么处理。
+
+---
+
+## 二、问题一:每轮 active agent 数量硬编码,与"千人模拟"业务叙事冲突
+
+### 现象
+
+在 [`backend/app/services/engine.py` 的 `_select_active_agents`](../backend/app/services/engine.py) 里,每一轮参与决策的市民数量由这两个常量决定:
+
+```python
+active_agents_per_round_min: int = 3
+active_agents_per_round_max: int = 10
+```
+
+来源:[`backend/app/models/world.py` 的 `TimeConfig`](../backend/app/models/world.py)
+
+### 我观察到的问题
+
+1. **抽样率随人口规模塌缩。** 100 人社区 → 3%–10% 采样,1000 人 → 0.3%–1%,10000 人 → 0.03%。但产品的对外叙事是"千人社会模拟"。
+2. **业务参数 vs 工程参数混淆。** 这两个数字看起来是 `TimeConfig`(业务/世界设定),但更像是 LLM 成本阀门。混在一起,没办法独立调。
+3. **`len(agents) * 0.6` 这个上限看起来是为更大规模预留的,但在默认 3–10 的基础上永远是死代码。** 暗示当初设计是想过随规模调整的。
+4. **3–10 个人的表态,能涌现出什么?** 默认权重 w=0.3,平静的人天然偏低,抽样会偏向情绪激动的小群体。
+5. **业务承诺 vs 实际行为错配。** 用户为"千人社会模拟"付费,生成阶段认真做了 1000 个 backstory;一轮跑下来认真读 prompt 的只有 6 个。这到底是性能问题还是产品问题?
+
+### 待澄清的几个点
+
+- 为什么 `min/max` 默认是 3–10?
+- 是否有人为"千人"规模做过代表性 / 抽样率的论证?
+- 这个参数应该让用户在产品层感知到(作为旋钮),还是只放在工程内部?
+
+---
+
+## 三、问题二:搜索后端从 DuckDuckGo 替换为 SearXNG
+
+### 现象
+
+现在 [`backend/app/services/research.py`](../backend/app/services/research.py) 用的是 `duckduckgo_search`:
+
+```python
+from duckduckgo_search import DDGS
+with DDGS() as ddgs:
+ return list(ddgs.text(query, max_results=max_results))
+```
+
+我想替换为自托管的 SearXNG,原因(这里只描述动机,不给方案):
+
+- DDG 经常触发反爬 / CAPTCHA,结果不稳定
+- 模拟场景需要可重复性(同一 query 多次结果尽量一致)
+- 想避免外部限流风险
+
+### 相关材料(已确认)
+
+项目里已有 SearXNG 的 skill 文档:[`skills/searxng_skills/`](../skills/searxng_skills/)
+
+关键信息(待修改时要严格遵守):
+
+- **域名是 `searxng.erhe.top`**(不是 `ehre.top` — `h`/`r` 反着,#1 常见坑)⚠
+- **必须带 `token=fGWEFWGrgasegtjt1jUJG`**,否则 403
+- 当前服务器 GET 即可(本地 stock docker 默认 POST,**不要照搬本地配置**)
+- JSON 200 路径:`/search?format=json&token=...`
+- 返回字段:`title / content / url`,需要重命名映射到现有 `title / body / href`
+
+### 已处理
+
+- ✅ 域名拼写错误问题,今天已修。
+
+### 待处理(明天动手)
+
+- [ ] `research.py` 的 `_raw_search` 替换为 SearXNG 调用
+- [ ] `__init__` 加 `searxng_base_url / searxng_token / searxng_timeout` 参数
+- [ ] `config.py` 加对应 `Settings` 字段
+- [ ] `main.py` 注入新参数
+- [ ] `.env` 写 `SEARXNG_BASE_URL` / `SEARXNG_TOKEN` / `SEARXNG_TIMEOUT`
+- [ ] 是否保留 DDG fallback?待定
+- [ ] 是否加 query 短缓存(避免同 query 短时间重复打后端)?待定
+
+### 还需要决定的
+
+- `searxng_timeout` 取多少?skills 里两处不一致(`SKILL.md` 是 30,`searxng_skills.md` 是 15)。模拟里 30 太久(卡轮),5 太短。倾向 15。
+- `safesearch` 取 0 还是 1?模拟里有些社会议题可能需要未过滤内容。
+
+---
+
+## 四、相关代码位置
+
+- 抽样逻辑:`backend/app/services/engine.py` `_select_active_agents`(约 765–833 行)
+- 抽样配置:`backend/app/models/world.py` `TimeConfig`(约 14–17 行)
+- 搜索实现:`backend/app/services/research.py`
+- 搜索配置:`backend/app/config.py` `Settings.search_enabled / max_searches_per_round`
+- LLM 客户端 / semaphore:`backend/app/services/llm.py`
+- LLM 并发配置:`backend/app/config.py` `Settings.max_concurrent_llm_calls`
+- SearXNG skill 文档:`skills/searxng_skills/SKILL.md`
+
+---
+
+## 五、与上一份记录的关系
+
+这是第二份问题记录(第一份是 [问题记录-硬编码与千人模拟.md](./问题记录-硬编码与千人模拟.md))。
+
+两者的区别:
+
+- **第一份** 只聚焦硬编码 / 千人模拟的业务问题(问题一),是完整提问人视角的论述。
+- **这份** 把搜索替换作为独立问题追加进来,并标注"今天已修 / 明天待办"的拆分。
\ No newline at end of file
diff --git a/docs/问题记录-硬编码与千人模拟.md b/docs/问题记录-硬编码与千人模拟.md
new file mode 100644
index 0000000..e098787
--- /dev/null
+++ b/docs/问题记录-硬编码与千人模拟.md
@@ -0,0 +1,138 @@
+# 问题记录:硬编码与"千人社会"模拟的业务一致性
+
+> 记录日期:2026-09-27
+> 记录人:项目相关方
+> 状态:仅记录问题,不附解决方案
+
+---
+
+## 背景
+
+我想搞清楚一个问题:在当前的项目实现里,如果用户设置 1000 个市民,整个模拟引擎到底是怎么跑的?我担心两个事情:
+
+1. 供应商的 TPS 顶不顶得住;
+2. 这个所谓的"千人社会模拟",在业务层面到底是不是真的有意义。
+
+下面是我一边看代码一边问出来的问题。
+
+---
+
+## 一、先确认运行机制
+
+我看到的是这样一条路径:
+
+- 模拟每一天被分成三轮(morning / afternoon / evening),全年 365 天,共 1095 轮。
+- 每一轮里,并不是所有人都去问 LLM,而是有一个 `_select_active_agents` 的机制去抽样。
+- 抽样的基准是 `active_agents_per_round_min = 3`、`active_agents_per_round_max = 10`,带情绪、事件、派系、沉睡等权重加成。
+- 然后这一批被抽中的人,去并发地打 LLM,做决策。
+- 之后再走反应、研究、叙事等若干个 LLM 调用。
+
+看到这里的时候,我的第一个问题浮出来了。
+
+---
+
+## 二、问题
+
+### 问题 1:抽样率随人口规模塌缩
+
+1000 人的社区,每一轮只有 3–10 个人被问,这是什么比例?
+
+- 100 人社区:3%–10%(看起来是合理的"采样")
+- 1000 人社区:0.3%–1%
+- 10000 人社区:0.03%
+
+我卖的产品叫"千人社会模拟"。可是跑出来,每一轮只有不到 10 个人在表态,那剩下 990 多个人在干什么?我看到的"社会"是从 3–10 个人里推出来的。
+
+### 问题 2:业务参数和成本参数混在同一个常量里
+
+那 3 和 10 到底是产品决策还是工程决策?
+
+- 如果是产品决策 —— 它凭什么不随人口规模变?
+- 如果是工程决策 —— 它为什么硬编码在 `TimeConfig` 这样一个看起来是"世界设定"的数据类里?
+
+我觉得这两个里总有一个不对劲。
+
+### 问题 3:上限被钳到 60%,但实际上根本用不到
+
+我注意到有这么一行:
+
+```python
+max_active = max(base_count, int(len(agents) * 0.6))
+target = min(base_count + (3 if event_occurred else 0), max_active)
+```
+
+看起来是给 1000 人准备的"60% 上限",但由于 `base_count` 只有 3–10,那个 0.6 的上限永远摸不到。也就是说这个上限是一个"为更大规模准备的逻辑",但在没有人为改 3–10 之前,它永远是死代码。
+
+那它在暗示什么?是不是原来设想里,这个 3–10 本来就应该随规模动起来?
+
+### 问题 4:3–10 个人的表态,能代表什么?
+
+我再往深想一层。民意、情绪、派系分布、冲突演化 —— 整个产品叙事都建立在"涌现"两个字上。
+
+那 3–10 个人做出的决策,能涌现出什么?尤其是当 active 抽样里还包含了"沉默偏向"(默认 w=0.3,平静的人权重天然偏低),抽样结果会进一步偏向情绪激动的小群体。
+
+所以这到底是"千人社会的涌现",还是"6 个最激动的人的噪声"?
+
+### 问题 5:业务承诺和实际行为之间,是不是有错配?
+
+用户为"千人社会模拟"付费。系统在生成阶段认真生成了 1000 个市民的 backstory、性格、记忆、关系。一轮跑下来,认真读了 prompt 的只有 6 个人。
+
+这种错配在业务上叫什么?我不太确定。但我直觉它不只是"性能问题"。
+
+### 问题 6:除了 active 数,还有哪些是硬编码的"看似业务参数"?
+
+我在 `world.py` 里看到了:
+
+```python
+total_days: int = 365
+rounds_per_day: int = 3
+active_agents_per_round_min: int = 3
+active_agents_per_round_max: int = 10
+```
+
+这四个数字看起来都是默认值。但 `total_days` 和 `rounds_per_day` 是用户故事的核心节奏参数 —— 用户应该在产品层能感知到它们。
+
+而 `active_agents_per_round_min/max` 这个东西,用户感知不到,因为它从来没出现在 UI 里。但它却对"千人模拟"的语义有决定性影响。
+
+这到底是一个用户应该配置的旋钮,还是一个工程内部值?我越看越觉得,它**应该**是前者。
+
+---
+
+## 三、我没有结论,只有问题
+
+到目前为止我没找到一份文档说明:
+
+1. 为什么 `active_agents_per_round_min/max` 默认是 3–10;
+2. 这个区间在不同人口规模下是怎么被论证过的;
+3. 它是被当作"LLM 成本阀门"还是"模拟质量参数"设计的;
+4. 是否有人为"千人社会"做过代表性 / 抽样率的论证。
+
+如果不回答这四个问题,"千人社会模拟"四个字,目前在产品上只是启动时填了一个数字而已。
+
+---
+
+## 四、补充记录:跟 TPS 那条线是分开的两件事
+
+我一开始问的是 TPS(供应商限流),得到的答案是:
+
+- semaphore 默认并发 10;
+- 每轮实际只打 3–10 次决策 LLM,加若干反应 / 研究 / 叙事;
+- 1000 人下瞬时并发不会超过 semaphore 上限。
+
+这条线结论清楚:当前实现下,**TPS 没问题**。
+
+但顺着这个问题我又往下看了一眼抽样代码,发现了上面那些"业务没意义"的疑问。所以这两件事其实是分开的两件事:
+
+- TPS:**没风险**(这是工程结论)
+- 千人模拟是否成立:**存疑**(这是产品结论)
+
+我把它们分开记在这里,免得有人把"TPS 没问题"误读成"千人模拟没问题"。
+
+---
+
+## 五、相关代码位置
+
+- 抽样逻辑:`backend/app/services/engine.py` `_select_active_agents`(约 765–833 行)
+- 配置常量:`backend/app/models/world.py` `TimeConfig`(约 14–17 行)
+- 决策并发:`backend/app/services/llm.py` `LLMClient.generate_batch`
+- 轮循环入口:`backend/app/services/engine.py` `run` 主循环(`while round_num < total_rounds`)
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index f4a9d5b..1e3d8f0 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,6 +10,8 @@
"dependencies": {
"axios": "^1.7.0",
"d3": "^7.9.0",
+ "html2canvas": "^1.4.1",
+ "jspdf": "^4.2.1",
"vue": "^3.5.0",
"vue-i18n": "^10.0.7",
"vue-router": "^4.4.0"
@@ -68,6 +70,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
@@ -1000,6 +1011,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/pako": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz",
+ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/raf": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz",
+ "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1202,6 +1233,15 @@
"proxy-from-env": "^1.1.0"
}
},
+ "node_modules/base64-arraybuffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+ "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
@@ -1319,6 +1359,26 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/canvg": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz",
+ "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@types/raf": "^3.4.0",
+ "core-js": "^3.8.3",
+ "raf": "^3.4.1",
+ "regenerator-runtime": "^0.13.7",
+ "rgbcolor": "^1.0.1",
+ "stackblur-canvas": "^2.0.0",
+ "svg-pathdata": "^6.0.3"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -1378,6 +1438,30 @@
"node": ">= 10"
}
},
+ "node_modules/core-js": {
+ "version": "3.50.0",
+ "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.50.0.tgz",
+ "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/css-line-break": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
+ "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+ "license": "MIT",
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1830,6 +1914,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dompurify": {
+ "version": "3.4.16",
+ "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.16.tgz",
+ "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optional": true,
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1996,6 +2090,17 @@
"node": ">= 6"
}
},
+ "node_modules/fast-png": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-6.4.0.tgz",
+ "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/pako": "^2.0.3",
+ "iobuffer": "^5.3.2",
+ "pako": "^2.1.0"
+ }
+ },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -2006,6 +2111,12 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "license": "MIT"
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2194,6 +2305,19 @@
"node": ">= 0.4"
}
},
+ "node_modules/html2canvas": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
+ "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+ "license": "MIT",
+ "dependencies": {
+ "css-line-break": "^2.1.0",
+ "text-segmentation": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -2215,6 +2339,12 @@
"node": ">=12"
}
},
+ "node_modules/iobuffer": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz",
+ "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
+ "license": "MIT"
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2287,6 +2417,23 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/jspdf": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-4.2.1.tgz",
+ "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "fast-png": "^6.2.0",
+ "fflate": "^0.8.1"
+ },
+ "optionalDependencies": {
+ "canvg": "^3.0.11",
+ "core-js": "^3.6.0",
+ "dompurify": "^3.3.1",
+ "html2canvas": "^1.0.0-rc.5"
+ }
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2437,6 +2584,22 @@
"node": ">= 6"
}
},
+ "node_modules/pako": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz",
+ "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2444,6 +2607,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/performance-now": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz",
+ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2672,6 +2842,16 @@
],
"license": "MIT"
},
+ "node_modules/raf": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz",
+ "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "performance-now": "^2.1.0"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -2695,6 +2875,13 @@
"node": ">=8.10.0"
}
},
+ "node_modules/regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -2727,6 +2914,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rgbcolor": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/rgbcolor/-/rgbcolor-1.0.1.tgz",
+ "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+ "license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.8.15"
+ }
+ },
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
@@ -2823,6 +3020,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackblur-canvas": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
+ "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.14"
+ }
+ },
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -2869,6 +3076,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/svg-pathdata": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
+ "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -2907,6 +3124,15 @@
"node": ">=14.0.0"
}
},
+ "node_modules/text-segmentation": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
+ "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+ "license": "MIT",
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -3036,6 +3262,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/utrie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
+ "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-arraybuffer": "^1.0.2"
+ }
+ },
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index fef88b5..00cb9d4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,6 +11,8 @@
"dependencies": {
"axios": "^1.7.0",
"d3": "^7.9.0",
+ "html2canvas": "^1.4.1",
+ "jspdf": "^4.2.1",
"vue": "^3.5.0",
"vue-i18n": "^10.0.7",
"vue-router": "^4.4.0"
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 625dc80..d11c822 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -133,10 +133,20 @@ export default {
return data
},
- async getPresets() {
- const { data } = await api.get('/presets')
- return data
- },
+ async getPresets() {
+ const { data } = await api.get('/presets')
+ return data
+ },
+
+ async getCustomPresets() {
+ const { data } = await api.get('/custom-presets')
+ return data
+ },
+
+ async createCustomPreset(title, content) {
+ const { data } = await api.post('/custom-presets', { title, content })
+ return data
+ },
async getForecast(simulationId) {
const { data } = await api.get(`/simulation/${simulationId}/forecast`)
diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue
index 451e6e1..2d84407 100644
--- a/frontend/src/components/AppShell.vue
+++ b/frontend/src/components/AppShell.vue
@@ -20,17 +20,32 @@
{{ activeSim.name || $t('app.nav.activeSimFallback') }}
-
- {{ $t('app.nav.gallery') }}
-
-
-
+ {{ $t('app.nav.gallery') }}
+
+
+
+
+
@@ -40,14 +55,32 @@