++
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user