# Real City Inference & Direct Agent Control — Design > 2026-09-18 > 位置:`docs/new_plan/`(区别于 `docs/plans/` 里已归档的设计) > 记录两个产品增强方向:(1) 规则文本里提到城市时自动启用真实人口结构;(2) 用户在模拟运行中可直接与居民交互(私聊、塞情报),把 MiroSociety 从「观察实验」升级为「可介入实验」。 ## 背景 当前 MiroSociety 的产品定位是「看涌现」—— 用户定义规则,系统跑出结果。但在真实使用中,玩家常自然产生两类诉求: 1. **我希望自己的规则能更接近真实**:写「旧金山房租涨 30%」时,期望模拟的是真实的旧金山人,而不是 10 个随机抽样的居民。 2. **我希望在中途介入**:加天灾、跟某个人说句话、给某个人一条情报,看看这个社会对「定向刺激」会怎么反应。 这两类诉求都对应明确的代码改动,且与现有架构兼容。详见下方的「补齐方案」。 ## 现状梳理 ### 真实城市相关 - 后端字段定义: [`backend/app/api/simulate.py:48`](../../backend/app/api/simulate.py#L48) `city: str | None = None`,无默认值 - 后端调用入口: [`backend/app/api/simulate.py:139-155`](../../backend/app/api/simulate.py#L139-L155),仅在 `req.city` 有值时走 `CensusService.get_profile` - 前端表单: [`frontend/src/views/HomeView.vue:53`](../../frontend/src/views/HomeView.vue#L53) 城市输入框默认收起,只有点「+ 基于真实城市」才展开 - 前端 API 客户端: [`frontend/src/api/client.js:23`](../../frontend/src/api/client.js#L23) 空字符串转 `null` - 已支持城市列表: [`backend/app/services/census.py:18-40`](../../backend/app/services/census.py#L18-L40),共 20 个美国城市 - 数据来源: 美国人口普查局 Census API,年龄段/收入/族裔/职业/贫困率 **当前行为**:用户在规则里写「旧金山」,系统**不会**自动匹配,智能体人口结构完全随机。这与「随便写就行」的产品直觉不符。 ### 直接控制相关 | 玩家想做 | 当前能力 | 代码位置 | |---|---|---| | 加天灾 / 黑天鹅 | ✅ 支持,广播式 | [`engine.py:2116-2147`](../../backend/app/services/engine.py#L2116-L2147) | | 居民互相打听 | ✅ 支持(内部 INVESTIGATE 动作) | [`engine.py:1992-2047`](../../backend/app/services/engine.py#L1992-L2047) | | 居民互相私聊 | ✅ 支持(内部 SPEAK_PRIVATE 动作) | [`engine.py:1636`](../../backend/app/services/engine.py#L1636) | | 用户定向跟某居民说话 | ❌ 无接口 | — | | 用户给某居民塞情报 / 改信念 | ❌ 无接口 | — | | 暂停状态下介入 | ❌ 暂停期间只能 inject 事件,不能定向传话 | — | 技术上,运行时改智能体的 `beliefs` / `core_memory` / `working_memory` 已经支持(`deliver_enriched_agents` 是先例,见 [`engine.py:329`](../../backend/app/services/engine.py#L329))。缺的只是 **API + 前端交互**。 ## 补齐方案 ### 1. 规则文本中城市名自动匹配 #### 思路 在 `run_pipeline` 的早期(状态切到 `GENERATING_WORLD` 之前),用轻量规则解析扫描 `rules` 文本: - 若 `req.city` 已有值 → 跳过,优先级用户显式 > 文本推断 - 若 `req.city` 为空 → 在文本里找已知城市列表的别名(包括中英文、缩写),命中则自动填入 `req.city` - 若未命中 → 行为不变,完全随机人口 #### 数据:已知城市的别名表 在 [`census.py`](../../backend/app/services/census.py) 里新增 `CITY_ALIASES`: ```python CITY_ALIASES: dict[str, str] = { # 英文 "san francisco": "san francisco, ca", "sf": "san francisco, ca", "new york": "new york, ny", "nyc": "new york, ny", "los angeles": "los angeles, ca", "la": "los angeles, ca", # 中文 "旧金山": "san francisco, ca", "纽约": "new york, ny", "洛杉矶": "los angeles, ca", "芝加哥": "chicago, il", "西雅图": "seattle, wa", "奥斯汀": "austin, tx", "波士顿": "boston, ma", "迈阿密": "miami, fl", "亚特兰大": "atlanta, ga", "底特律": "detroit, mi", # ... 覆盖 20 个城市 } ``` #### 改动范围 - **新增** [`backend/app/services/city_resolver.py`](../../backend/app/services/city_resolver.py) — 文本扫描与别名匹配工具 - **修改** [`backend/app/api/simulate.py:139`](../../backend/app/api/simulate.py#L139) 在 `if req.city:` 之前插入自动解析 - **SSE 事件**:新增 `city_inferred` 事件,告诉前端「我们从规则里识别到了旧金山,已启用真实人口」 - **日志**:记录推断过程,便于排查误识别 #### 风险与缓解 - **误识别**:「我旧金山的表弟今天结婚」这句话里出现「旧金山」,但用户是想讨论亲戚婚礼,不是想测试旧金山人 —— 缓解方式:把识别结果**作为提示**,前端弹出「我们识别到旧金山,是否启用真实人口结构?」让用户确认一次 - **歧义**:某些城市名(如 Portland)在多个州都有 → 已在 `CITY_FIPS` 里标注,匹配时选人口最多的那个 - **非美城市**:目前只支持美国,匹配不到就不报错,行为不变 #### 不做的事 - 不做模糊匹配(避免误识别),只做精确子串匹配 - 不做 NLP 抽取,只做字符串扫描 — 快、可控、易调试 --- ### 2. 用户定向与居民交互 #### 目标 让玩家可以在模拟运行中: - 对某个居民**说一句话**(类似神谕/上帝视角 whisper),该居民下一轮会按自己性格回应 - 给某个居民**塞一条情报**(改她的 belief / core_memory),影响她后续判断 #### 接口设计 新增两个 FastAPI 接口,都在 `/simulation/{sim_id}` 下: ```python class DirectSpeakRequest(BaseModel): agent_id: int message: str as: Literal["god", "voice_in_head", "neighbor"] = "voice_in_head" @router.post("/simulation/{sim_id}/direct-speak") async def direct_speak(sim_id, req, request): ... class PlantInfoRequest(BaseModel): agent_id: int claim: str # 例如「Alice 偷了你的东西」 target_agent_id: int | None # 可选,情报是关于某个人的 source_label: str = "reliable neighbor" @router.post("/simulation/{sim_id}/plant-info") async def plant_info(sim_id, req, request): ... ``` #### 后端逻辑 **`direct-speak`**: 1. 从 `store.get_agent(sim_id, agent_id)` 拿到该居民 2. 把 `message` 写入 `agent.working_memory` 顶部: ``` "Day {day}: You suddenly hear a voice in your head saying: \"{message}\". You're not sure where it came from, but it feels real." ``` 3. 触发该居民在**下一轮**进入激活列表(`active_agents` 强制包含) 4. `save_agents_batch` 持久化 5. SSE 推 `direct_speak_delivered` 事件 **`plant-info`**: 1. 拿到目标居民 2. 把 `claim` 作为新信念加入 `agent.beliefs`: ``` "Day {day}: A {source_label} told you: {claim}" ``` 3. 如果指定了 `target_agent_id`,同时降低目标居民在该居民 `relationships` 里的 `sentiment`(0.2-0.4) 4. 持久化 + SSE 事件 `info_planted` #### 改动范围 - **新增** [`backend/app/api/direct_control.py`](../../backend/app/api/direct_control.py) - **修改** [`backend/app/main.py:110-113`](../../backend/app/main.py#L110-L113) 注册新路由 - **修改** [`backend/app/services/engine.py:407`](../../backend/app/services/engine.py#L407) `_select_active_agents` 支持「强制激活」列表 - **前端** [`frontend/src/components/AgentDetailPanel.vue`](../../frontend/src/components/AgentDetailPanel.vue) 增加两个按钮: - 「对她说句话」→ 弹小输入框 - 「给她一条情报」→ 弹输入框 + 可选「情报是关于谁的」下拉 #### 状态机注意事项 - **暂停时调用**:应当入队,resume 后下一轮再触发(避免和 main loop 抢锁) - **取消时调用**:直接拒绝,返回 `409 simulation_cancelled` - **已完成时调用**:返回 `410 simulation_complete`,不允许干预 - **重复调用**:同一居民同一轮多次 whisper,合并成一条 working_memory(避免爆栈) #### 隐私 / 公平设计 - 所有直接干预**记入 `action_log`**,标记来源是 `user_direct_speak` / `user_planted_info`,便于事后复盘「这条信念是被植入的」 - 前端 UI 显示一个「✋ 用户干预」角标,让观众知道这个居民在某些点上被外力影响过 - 报告生成器(`narrator.generate_report`)把用户干预事件作为单独章节列出 #### 不做的事 - 不做多选批量干预(一次只针对一个居民,保持简单) - 不做定时任务(不能在第 10 天自动给她塞情报),玩家手动控制时机 - 不做语音输入,只用文字 --- ## 优先级建议 如果分阶段上线: | 阶段 | 内容 | 预估工作量 | |---|---|---| | **P0** | 城市自动匹配(方案 1) | 1-2 天,改动小,立刻提升「随手写规则」的体验 | | **P1** | direct-speak 接口 + 前端按钮 | 3-4 天,核心玩法升级 | | **P2** | plant-info 接口 | 2-3 天,在 P1 基础上复用持久化逻辑 | | **P3** | 干预标记 + 报告章节 | 1 天,可观测性补完 | P0 单独立项即可上线,P1+P2 一起做可以串成「上帝模式」主题更新。 ## 后续可能扩展 - **多选 / 批量干预**:一次对一组人说话(类似群体喊话) - **定时干预**:cron 风格,在第 N 天自动触发某事件 - **干预模板**:把常用的「告诉你邻居是坏人」「告诉你老板要裁员」做成快捷按钮 - **其他国家的城市**:接入中国/欧洲的人口数据源,扩大可玩范围 - **真实天气事件 API**:对接 NOAA / 和风天气,让天灾更真实