This commit is contained in:
2026-09-18 15:21:47 +08:00
parent 0d8c34a2c9
commit aa27d35a47
55 changed files with 2842 additions and 471 deletions
+62 -14
View File
@@ -10,6 +10,7 @@ from app.models.agent import (
from app.models.demographics import DemographicProfile
from app.models.world import WorldBlueprint
from app.services.llm import LLMClient, parse_json
from app.services.i18n import language_directive
logger = logging.getLogger(__name__)
@@ -242,8 +243,21 @@ Return ONLY valid JSON. No markdown, no explanation."""
class CitizenGenerator:
def __init__(self, llm: LLMClient):
def __init__(self, llm: LLMClient, locale: str = "en"):
self.llm = llm
self._locale = locale
def set_locale(self, locale: str) -> None:
self._locale = locale
@staticmethod
def _has_cjk(text: str) -> bool:
return any("\u4e00" <= ch <= "\u9fff" for ch in text)
def _directive(self, system_prompt: str) -> str:
"""Wrap a system prompt with the language directive (head + tail)."""
head, tail = language_directive(self._locale)
return head + "\n\n" + system_prompt + "\n\n" + tail
async def generate(
self,
@@ -337,7 +351,7 @@ class CitizenGenerator:
) or " (not available)"
char_line = f"CITY CHARACTER: {demographics.city_character}" if demographics.city_character else ""
system_prompt = DEMOGRAPHIC_CAST_PROMPT.format(
base = DEMOGRAPHIC_CAST_PROMPT.format(
city_name=demographics.city_name,
state=demographics.state,
population=demographics.population,
@@ -350,8 +364,9 @@ class CitizenGenerator:
eth_dist=eth_dist,
city_character=char_line,
)
system_prompt = self._directive(base)
else:
system_prompt = CAST_SYSTEM_PROMPT
system_prompt = self._directive(CAST_SYSTEM_PROMPT)
user_text = f"{world_context}\n\nGenerate exactly {count} citizens."
if proposed_change:
@@ -367,7 +382,7 @@ class CitizenGenerator:
system=system_prompt,
user=user_text,
json_mode=True,
max_tokens=3000,
max_tokens=4000,
)
data = parse_json(response)
@@ -378,17 +393,50 @@ class CitizenGenerator:
else:
cast = []
# If the user picked a Chinese locale but the LLM ignored our language
# directive and returned English names, retry once with a stronger nudge.
if (
self._locale == "zh"
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])
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)
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)
while len(cast) < count:
idx = len(cast)
cast.append({
"name": f"Citizen {idx + 1}",
"role": "Laborer",
"age": 30 + (idx % 40),
"personality_hook": "A quiet person trying to get by",
"stance": "Indifferent to the rules",
})
if self._locale == "zh":
cast.append({
"name": f"居民{idx + 1}号",
"role": "劳动者",
"age": 30 + (idx % 40),
"personality_hook": "一个安静过日子的人",
"stance": "对规则漠不关心",
})
else:
cast.append({
"name": f"Citizen {idx + 1}",
"role": "Laborer",
"age": 30 + (idx % 40),
"personality_hook": "A quiet person trying to get by",
"stance": "Indifferent to the rules",
})
return cast[:count]
@@ -418,7 +466,7 @@ class CitizenGenerator:
f"Personality hook: {member.get('personality_hook', '')}\n"
f"Stance on tensions: {member.get('stance', 'unknown')}"
)
prompts.append((PERSONA_SYSTEM_PROMPT, f"{world_ctx}\n\n{citizen_ctx}"))
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)
@@ -482,7 +530,7 @@ class CitizenGenerator:
)
response = await self.llm.generate(
system=RELATIONSHIPS_SYSTEM_PROMPT,
system=self._directive(RELATIONSHIPS_SYSTEM_PROMPT),
user=f"World: {blueprint.name}\nRules: {'; '.join(blueprint.rules)}\n\nCitizens:\n{agent_summaries}",
json_mode=True,
max_tokens=3000,
@@ -551,7 +599,7 @@ class CitizenGenerator:
prompts = []
for agent in batch:
num_events = self._num_formative_events(agent.age)
system = LIFE_HISTORY_PROMPT.format(num_events=num_events)
system = self._directive(LIFE_HISTORY_PROMPT.format(num_events=num_events))
world_ctx = (
f"World: {blueprint.name}\n"
f"Rules: {'; '.join(blueprint.rules)}\n"
+9
View File
@@ -656,6 +656,15 @@ class SimulationEngine:
},
))
await self.store.update_status(simulation_id, SimulationStatus.COMPLETED.value)
# Auto-publish to the gallery if the user opted in. We read the
# flag from the sim's own meta table so we don't have to thread
# it through the entire engine.run signature.
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)
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:
+330
View File
@@ -0,0 +1,330 @@
"""Tiny i18n helpers for localizing API error messages.
We intentionally avoid pulling in a heavy dependency (Babel / gettext) for what is
essentially a fixed dictionary of user-facing strings. Supported locales are kept
in sync with the frontend's SUPPORTED_LOCALES list in
`frontend/src/locales/index.js`.
"""
from __future__ import annotations
from typing import Iterable
from fastapi import Request
SUPPORTED_LOCALES = ("en", "zh")
DEFAULT_LOCALE = "en"
# Each value is a dict keyed by locale code. English is the fallback.
_MESSAGES: dict[str, dict[str, str]] = {
"rules_cannot_be_empty": {
"en": "Rules cannot be empty",
"zh": "规则不能为空",
},
"population_range": {
"en": "Population must be between 2 and 50",
"zh": "人口规模必须在 2 到 50 之间",
},
"duration_range": {
"en": "Duration must be between 1 and 365 days",
"zh": "持续时间必须在 1 到 365 天之间",
},
"simulation_not_found": {
"en": "Simulation not found",
"zh": "未找到模拟",
},
"simulation_pipeline_gone": {
"en": "Simulation pipeline is not live (server may have restarted). Open the report view or start a new simulation.",
"zh": "模拟管线已下线(可能是服务器重启了)。请打开报告页查看已保存的状态,或重新发起模拟。",
},
"simulation_interrupted_no_report": {
"en": "Simulation was interrupted (server restarted). No partial report is available — please start a new simulation.",
"zh": "模拟已中断(服务器重启过)。暂无部分报告可用 —— 请重新发起模拟。",
},
"no_world_state": {
"en": "No world state",
"zh": "无世界状态",
},
"simulation_not_running": {
"en": "Simulation not running",
"zh": "模拟未运行",
},
"source_simulation_not_found": {
"en": "Source simulation not found",
"zh": "未找到源模拟",
},
"agent_not_found": {
"en": "Agent not found",
"zh": "未找到智能体",
},
"invalid_speed_mode": {
"en": "Invalid speed mode: {mode}",
"zh": "无效的速度模式:{mode}",
},
"fork_state_not_found": {
"en": "Fork state not found",
"zh": "未找到分叉状态",
},
"no_metrics_data": {
"en": "No metrics data found",
"zh": "未找到指标数据",
},
"forecast_not_available": {
"en": "Forecast service not available",
"zh": "预测服务不可用",
},
"report_generation_failed": {
"en": "Report generation failed",
"zh": "报告生成失败",
},
"simulation_cancelled": {
"en": "Simulation cancelled",
"zh": "模拟已取消",
},
"language_name": {
"en": "English",
"zh": "中文",
},
"language_instruction": {
"en": "Write all output in English.",
"zh": "请用中文撰写所有输出。",
},
}
def parse_accept_language(header: str | None) -> str:
"""Pick the best supported locale from an Accept-Language header."""
if not header:
return DEFAULT_LOCALE
for raw in header.split(","):
tag = raw.split(";")[0].strip().lower()
if not tag:
continue
primary = tag.split("-")[0]
if primary in SUPPORTED_LOCALES:
return primary
return DEFAULT_LOCALE
def locale_from_request(request: Request) -> str:
return parse_accept_language(request.headers.get("accept-language"))
def translate(key: str, locale: str, /, **kwargs) -> str:
"""Return the localized message for `key`, falling back to English then key."""
entry = _MESSAGES.get(key)
if not entry:
return key
template = entry.get(locale) or entry.get(DEFAULT_LOCALE) or key
if kwargs:
try:
return template.format(**kwargs)
except (KeyError, IndexError):
return template
return template
def language_instruction(locale: str) -> str:
"""Return a short prompt instruction telling the LLM which language to use."""
return translate("language_instruction", locale)
def language_directive(locale: str) -> tuple[str, str]:
"""Strong language directive to bracket a system prompt.
Returns (head, tail). Place `head` at the start and `tail` at the end of
the system prompt so the LLM is reminded of the language constraint at
both ends. Most modern chat models follow instructions more reliably when
they appear in the top section of the prompt.
"""
head = (
f"== LANGUAGE REQUIREMENT ==\n"
+ language_instruction(locale)
+ "\nYour entire response MUST be in the language above. "
"Do NOT mix languages. If a name or technical term is originally "
"in another language, transliterate or translate it; do not leave "
"English fragments in a Chinese response (or vice versa)."
)
tail = "[Reminder: write your entire response in the language specified above.]"
return head, tail
def supported_locales() -> Iterable[str]:
return SUPPORTED_LOCALES
# ---------------------------------------------------------------------------
# Domain translations for report fields. Keys are the canonical enum strings
# used in the data model; values are per-locale display strings.
# ---------------------------------------------------------------------------
_METRIC_LABELS = {
"stability": {"en": "Stability", "zh": "稳定性"},
"prosperity": {"en": "Prosperity", "zh": "繁荣度"},
"trust": {"en": "Trust", "zh": "信任度"},
"freedom": {"en": "Freedom", "zh": "自由度"},
"conflict": {"en": "Conflict", "zh": "冲突度"},
"brand_sentiment": {"en": "Brand Sentiment", "zh": "品牌口碑"},
"purchase_intent": {"en": "Purchase Intent", "zh": "购买意向"},
"word_of_mouth": {"en": "Word of Mouth", "zh": "口碑传播"},
"churn_risk": {"en": "Churn Risk", "zh": "流失风险"},
"adoption_rate": {"en": "Adoption Rate", "zh": "采纳率"},
}
_RATINGS = {
"excellent": {"en": "Excellent", "zh": "极佳"},
"strong": {"en": "Strong", "zh": "良好"},
"moderate": {"en": "Moderate", "zh": "中等"},
"weak": {"en": "Weak", "zh": "偏弱"},
"critical": {"en": "Critical", "zh": "危险"},
}
_TRENDS = {
"up": {"en": "up", "zh": "上升"},
"down": {"en": "down", "zh": "下降"},
"flat": {"en": "flat", "zh": "平稳"},
}
_VERDICTS = {
"go": {"en": "Proceed", "zh": "推进"},
"caution": {"en": "Caution", "zh": "谨慎"},
"rethink": {"en": "Rethink", "zh": "重新思考"},
}
_CONFIDENCE = {
"high": {"en": "high", "zh": "高"},
"medium": {"en": "medium", "zh": "中"},
"low": {"en": "low", "zh": "低"},
}
_SEGMENT_FUNNEL = {
"aware": {"en": "Aware", "zh": "知晓"},
"interested": {"en": "Interested", "zh": "感兴趣"},
"tried": {"en": "Tried", "zh": "试用"},
"adopted": {"en": "Adopted", "zh": "采纳"},
"churned": {"en": "Churned", "zh": "流失"},
}
_EMOTIONAL_STATES = {
"calm": {"en": "Calm", "zh": "平静"},
"content": {"en": "Content", "zh": "满足"},
"curious": {"en": "Curious", "zh": "好奇"},
"hopeful": {"en": "Hopeful", "zh": "期盼"},
"satisfied": {"en": "Satisfied", "zh": "满意"},
"restless": {"en": "Restless", "zh": "躁动"},
"uneasy": {"en": "Uneasy", "zh": "不安"},
"confused": {"en": "Confused", "zh": "困惑"},
"frustrated": {"en": "Frustrated", "zh": "沮丧"},
"anxious": {"en": "Anxious", "zh": "焦虑"},
"angry": {"en": "Angry", "zh": "愤怒"},
"frustrated": {"en": "Frustrated", "zh": "沮丧"},
"hostile": {"en": "Hostile", "zh": "敌对"},
"fearful": {"en": "Fearful", "zh": "恐惧"},
"desperate": {"en": "Desperate", "zh": "绝望"},
"torn": {"en": "Torn", "zh": "撕裂"},
"resigned": {"en": "Resigned", "zh": "认命"},
"indifferent": {"en": "Indifferent", "zh": "漠然"},
}
_SEGMENT_FALLBACK_NAMES = {
# Used when the LLM never generated a proper segment (so we fall back to
# the agent's role). Keys are lowercased role names.
"laborer": {"en": "Laborer", "zh": "劳动者"},
"merchant": {"en": "Merchant", "zh": "商人"},
"scholar": {"en": "Scholar", "zh": "学者"},
"guard": {"en": "Guard", "zh": "守卫"},
"leader": {"en": "Leader", "zh": "领袖"},
"general": {"en": "General", "zh": "普通居民"},
"worker": {"en": "Worker", "zh": "工人"},
"farmer": {"en": "Farmer", "zh": "农夫"},
"teacher": {"en": "Teacher", "zh": "教师"},
"artist": {"en": "Artist", "zh": "艺术家"},
}
def _pick(mapping: dict[str, dict[str, str]], key: str, locale: str, *, default: str | None = None) -> str:
"""Pick the localized string for an enum key, falling back to English."""
entry = mapping.get(key)
if not entry:
return default if default is not None else key
if locale in entry:
return entry[locale]
return entry.get("en", default if default is not None else key)
def metric_label(key: str, locale: str) -> str:
return _pick(_METRIC_LABELS, key, locale)
def rating_label(key: str, locale: str) -> str:
return _pick(_RATINGS, key, locale)
def trend_label(key: str, locale: str) -> str:
return _pick(_TRENDS, key, locale)
def verdict_label(key: str, locale: str) -> str:
return _pick(_VERDICTS, key, locale, default=key.capitalize() if key else "—")
def confidence_label(key: str, locale: str) -> str:
return _pick(_CONFIDENCE, key, locale)
def segment_funnel_label(key: str, locale: str) -> str:
return _pick(_SEGMENT_FUNNEL, key, locale)
def emotional_state_label(key: str, locale: str) -> str:
return _pick(_EMOTIONAL_STATES, key, locale)
def localize_report(report: dict, locale: str) -> dict:
"""Recursively translate known enum strings in a report dict to the
requested locale. Unknown keys pass through unchanged.
This walks the structure used by the Narrator.generate_report output and
rewrites label/rating/trend/verdict/confidence/funnel fields in place.
Strings not in our maps (LLM-generated headlines, summaries, action quotes,
etc.) are left alone — they should already be in the user's language
because of the prompt-level language directive.
"""
if isinstance(report, dict):
out = {}
for k, v in report.items():
if k == "label" and isinstance(v, str):
out[k] = metric_label(v, locale)
elif k == "rating" and isinstance(v, str):
out[k] = rating_label(v, locale)
elif k == "trend" and isinstance(v, str):
out[k] = trend_label(v, locale)
elif k == "verdict" and isinstance(v, str):
out[k] = verdict_label(v, locale)
elif k == "confidence" and isinstance(v, str):
out[k] = confidence_label(v, locale)
elif k == "funnel" and isinstance(v, dict):
out[k] = {fk: segment_funnel_label(fv, locale) if isinstance(fv, str) else fv
for fk, fv in v.items()}
elif k == "emotional_state" and isinstance(v, str):
out[k] = emotional_state_label(v, locale)
elif k == "quality" and isinstance(v, str):
out[k] = rating_label(v, locale)
elif k == "name" and isinstance(v, str) and locale == "zh" and not _has_cjk(v):
# segment / role fallback name like "Laborer" — translate via
# the small fallback map. Strings that already contain CJK are
# left alone (LLM wrote a proper Chinese name).
out[k] = _SEGMENT_FALLBACK_NAMES.get(v.lower(), {}).get("zh", v)
elif isinstance(v, (dict, list)):
out[k] = localize_report(v, locale)
else:
out[k] = v
return out
if isinstance(report, list):
return [localize_report(item, locale) for item in report]
return report
def _has_cjk(text: str) -> bool:
return any("\u4e00" <= ch <= "\u9fff" for ch in text)
+53
View File
@@ -51,6 +51,9 @@ class LLMClient:
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:
@@ -83,16 +86,66 @@ class LLMClient:
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"<think>.*?</think>", "", 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:
+57 -15
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING
from app.models.action import ActionEntry, ReactiveResponse
from app.models.world import WorldState, WorldMetrics
from app.services.llm import LLMClient, parse_json
from app.services.i18n import DEFAULT_LOCALE, language_directive as _language_directive, localize_report
from app.constants import TIMES_OF_DAY
if TYPE_CHECKING:
@@ -127,9 +129,13 @@ Return JSON:
class Narrator:
def __init__(self, llm: LLMClient, forecast=None):
def __init__(self, llm: LLMClient, forecast=None, locale: str = DEFAULT_LOCALE):
self.llm = llm
self._forecast = forecast
self._locale = locale
def set_locale(self, locale: str) -> None:
self._locale = locale
async def narrate_round(
self,
@@ -170,10 +176,11 @@ class Narrator:
time_of_day = TIMES_OF_DAY[world_state.round_in_day % len(TIMES_OF_DAY)]
system = NARRATE_SYSTEM.format(
head, tail = _language_directive(self._locale)
system = head + "\n\n" + NARRATE_SYSTEM.format(
world_name=world_state.blueprint.name,
rules="; ".join(world_state.blueprint.rules),
)
) + f"\n\n{tail}"
user = NARRATE_USER.format(
previous_narrative=previous_narrative or "This is the beginning of the story.",
time_of_day=time_of_day,
@@ -195,7 +202,8 @@ class Narrator:
day_start: int,
day_end: int,
) -> str:
system = EPOCH_SYSTEM.format(world_name=world_name, rules="; ".join(rules))
head, tail = _language_directive(self._locale)
system = head + "\n\n" + EPOCH_SYSTEM.format(world_name=world_name, rules="; ".join(rules)) + f"\n\n{tail}"
narrative_text = "\n\n---\n\n".join(narratives[:20])
metrics_delta = (
@@ -232,8 +240,11 @@ class Narrator:
# --- Pass 0: Epoch summaries (existing logic) ---
epoch_size = 30
total_days = world_state.day
epochs = []
# Build the list of epochs that have narratives to summarize, then
# call the LLM concurrently for all of them. Epochs without any
# narratives (gap days) are skipped entirely.
pending_epochs: list[dict] = []
for start_day in range(1, total_days + 1, epoch_size):
end_day = min(start_day + epoch_size - 1, total_days)
epoch_narrs = [n["text"] for n in all_narratives if start_day <= n.get("day", 0) <= end_day]
@@ -249,10 +260,30 @@ class Narrator:
if day_of_round <= end_day:
m_end = WorldMetrics(**{k: m.get(k, getattr(WorldMetrics(), k)) for k in WorldMetrics.model_fields})
summary = await self.summarize_epoch(
blueprint.name, blueprint.rules, epoch_narrs, m_start, m_end, start_day, end_day
pending_epochs.append({
"start_day": start_day,
"end_day": end_day,
"narrs": epoch_narrs,
"m_start": m_start,
"m_end": m_end,
})
# Run all epoch summaries concurrently. The LLMClient already caps
# concurrency at 10 via its semaphore, so this just unblocks the
# event loop — we don't need an extra gather limit. Running 12 epochs
# serially could take 4+ minutes for a 365-day sim; concurrent runs
# collapse this to ~30s (the wall time of the slowest call).
epoch_summaries = await asyncio.gather(*(
self.summarize_epoch(
blueprint.name, blueprint.rules, ep["narrs"],
ep["m_start"], ep["m_end"], ep["start_day"], ep["end_day"],
)
epochs.append({"days": f"{start_day}-{end_day}", "summary": summary})
for ep in pending_epochs
))
epochs = [
{"days": f"{ep['start_day']}-{ep['end_day']}", "summary": summary}
for ep, summary in zip(pending_epochs, epoch_summaries)
]
epoch_text = "\n\n".join(f"Days {e['days']}:\n{e['summary']}" for e in epochs)
@@ -316,11 +347,12 @@ class Narrator:
for a in agent_stats
)
system_p2 = INTERPRET_METRICS_SYSTEM.format(
head, tail = _language_directive(self._locale)
system_p2 = head + "\n\n" + INTERPRET_METRICS_SYSTEM.format(
world_name=blueprint.name,
rules="; ".join(blueprint.rules),
confidence=confidence,
)
) + f"\n\n{tail}"
user_p2 = (
f"Epoch summaries:\n{epoch_text}\n\n"
f"Metric stats:\n{metrics_summary}\n\n"
@@ -358,10 +390,11 @@ class Narrator:
if seg["top_thoughts"]:
segment_input += "Recent thoughts:\n" + "\n".join(f" - {t}" for t in seg["top_thoughts"][-8:]) + "\n"
system_p3 = SEGMENT_ANALYSIS_SYSTEM.format(
head, tail = _language_directive(self._locale)
system_p3 = head + "\n\n" + SEGMENT_ANALYSIS_SYSTEM.format(
world_name=blueprint.name,
rules="; ".join(blueprint.rules),
)
) + 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)
@@ -398,10 +431,11 @@ class Narrator:
)
)
system_p4 = ACTION_SYNTHESIS_SYSTEM.format(
head, tail = _language_directive(self._locale)
system_p4 = head + "\n\n" + ACTION_SYNTHESIS_SYSTEM.format(
world_name=blueprint.name,
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)
@@ -415,7 +449,7 @@ class Narrator:
"description": f"{p['label']} shifted from {p['value_before']:.2f} to {p['value_after']:.2f}",
})
return {
report = {
"executive_brief": executive_brief,
"scorecard": scorecard,
"insights": insights,
@@ -442,3 +476,11 @@ class Narrator:
"counterfactuals": counterfactual_data,
"agent_coherence": coherence_data,
}
# We keep canonical English enum strings (label/rating/trend/
# verdict/confidence) in the response so the frontend can both:
# 1) pick the right CSS class for styling, and
# 2) translate the label for display via vue-i18n.
# LLM-generated free-text fields (summary, headlines, insights,
# action items, quotes) stay as-is — the prompt-level language
# directive should already have made them Chinese for zh users.
return report
+16 -6
View File
@@ -79,6 +79,10 @@ class TensionEngine:
self.llm = llm
self._stable_rounds: dict[str, int] = {}
self._quiet_rounds: dict[str, int] = {}
self._locale: str = "en"
def set_locale(self, locale: str) -> None:
self._locale = (locale or "en").lower().split("-")[0] or "en"
def cleanup(self, sim_id: str) -> None:
self._stable_rounds.pop(sim_id, None)
@@ -284,13 +288,19 @@ class TensionEngine:
names = ", ".join(t.name for t in targets)
if is_market:
event_desc = (
f"Consumer patience wore thin: {names} started seriously reconsidering their choices."
)
if self._locale.startswith("zh"):
event_desc = f"消费者的耐心终于被磨穿了:{names}开始认真重新考虑自己的选择。"
else:
event_desc = (
f"Consumer patience wore thin: {names} started seriously reconsidering their choices."
)
else:
event_desc = (
f"A quiet restlessness stirred among some citizens: {names} began to question things."
)
if self._locale.startswith("zh"):
event_desc = f"一阵隐隐的不安在部分居民中蔓延:{names}开始对现状产生质疑。"
else:
event_desc = (
f"A quiet restlessness stirred among some citizens: {names} began to question things."
)
self._snapshot_counterfactual(forecast, sim_id, day, event_desc)
for agent in targets:
+83 -11
View File
@@ -3,6 +3,7 @@ import logging
from app.models.world import WorldBlueprint, Location, TimeConfig
from app.services.llm import LLMClient, parse_json
from app.services.i18n import language_directive
logger = logging.getLogger(__name__)
@@ -45,14 +46,21 @@ Return ONLY valid JSON. No markdown, no explanation."""
class WorldGenerator:
def __init__(self, llm: LLMClient):
def __init__(self, llm: LLMClient, locale: str = "en"):
self.llm = llm
self._locale = locale
def set_locale(self, locale: str) -> None:
self._locale = locale
async def generate(
self, rules_text: str, population: int = 25, duration_days: int = 365,
proposed_change: str | None = None,
) -> WorldBlueprint:
is_market = proposed_change is not None
head, tail = language_directive(self._locale)
system = head + "\n\n" + SYSTEM_PROMPT + "\n\n" + tail
user_prompt = f"""Create a simulation world based on this context:
"{rules_text}"
@@ -65,10 +73,10 @@ active_agents_per_round_max should be about {max(3, int(population * 0.4))}"""
user_prompt += f'\n\nPROPOSED CHANGE being introduced into this world:\n"{proposed_change}"'
response = await self.llm.generate(
system=SYSTEM_PROMPT,
system=system,
user=user_prompt,
json_mode=True,
max_tokens=1500,
max_tokens=3000,
)
data = parse_json(response)
@@ -76,8 +84,15 @@ active_agents_per_round_max should be about {max(3, int(population * 0.4))}"""
logger.error("World generation produced empty result, using fallback")
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")
return self._fallback_blueprint(rules_text, population, duration_days, proposed_change)
try:
locations = self._default_locations()
locations = self._default_locations(self._locale)
tc = data.get("time_config", {})
time_config = TimeConfig(
@@ -97,10 +112,23 @@ active_agents_per_round_max should be about {max(3, int(population * 0.4))}"""
if r not in resources:
resources.append(r)
# The LLM may still return English names when the user picked a
# Chinese locale — fall back to localized defaults if its output
# doesn't look like Chinese.
name = data.get("name", "")
description = data.get("description", "")
if self._locale == "zh":
name = self._ensure_zh(name, fallback="无名之邦")
description = self._ensure_zh(description, fallback=f"一个这样的社会:{rules_text[:80]}")
fallback_rule = f"一个这样的社会:{rules_text[:80]}"
rules_list = [self._ensure_zh(r, fallback=fallback_rule) for r in data.get("rules", [rules_text])]
else:
rules_list = data.get("rules", [rules_text])
return WorldBlueprint(
name=data.get("name", "Unnamed Society"),
description=data.get("description", f"A society where: {rules_text[:100]}"),
rules=data.get("rules", [rules_text]),
name=name or "Unnamed Society",
description=description or f"A society where: {rules_text[:100]}",
rules=rules_list,
locations=locations,
resources=resources,
initial_tensions=data.get("initial_tensions", ["Order vs Freedom", "Individual vs Collective"]),
@@ -110,9 +138,37 @@ active_agents_per_round_max should be about {max(3, int(population * 0.4))}"""
logger.error("Failed to parse world blueprint: %s", e)
return self._fallback_blueprint(rules_text, population, duration_days, proposed_change)
def _default_locations(self) -> list[Location]:
@staticmethod
def _has_cjk(text: str) -> bool:
return any("\u4e00" <= ch <= "\u9fff" for ch in text)
def _ensure_zh(self, value: str, fallback: str) -> str:
"""In zh locale: require CJK content, otherwise fall back.
In any other locale: always pass through.
"""
if self._locale == "zh":
if value and self._has_cjk(value):
return value
return fallback
return value or fallback
def _default_locations(self, locale: str) -> list[Location]:
if locale == "zh":
return [
Location(
id="community",
name="公共空间",
type="public",
description="居民日常互动、聚集、议事的共享场所。",
),
]
return [
Location(id="community", name="Community", type="public", description="The shared social space"),
Location(
id="community",
name="Community",
type="public",
description="The shared social space",
),
]
def _fallback_blueprint(
@@ -124,11 +180,27 @@ active_agents_per_round_max should be about {max(3, int(population * 0.4))}"""
for r in ["money", "satisfaction"]:
if r not in resources:
resources.append(r)
if self._locale == "zh":
return WorldBlueprint(
name="无名之邦",
description=f"一个这样的社会:{rules_text[:80]}",
rules=[rules_text],
locations=self._default_locations("zh"),
resources=resources,
initial_tensions=["秩序 vs 自由", "个体 vs 集体"],
time_config=TimeConfig(
total_days=duration_days,
rounds_per_day=3,
active_agents_per_round_min=3,
active_agents_per_round_max=max(3, int(population * 0.4)),
),
)
return WorldBlueprint(
name="The Settlement",
description=f"A society where: {rules_text[:100]}",
rules=[rules_text],
locations=[Location(id="community", name="Community", type="public", description="The shared social space")],
locations=self._default_locations("en"),
resources=resources,
initial_tensions=["Order vs Freedom", "Individual vs Collective"],
time_config=TimeConfig(
@@ -137,4 +209,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)),
),
)
)