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 @@