Skip to content

Commit 134eef6

Browse files
wehosHongzhi Wenclaude
authored
改名记录修补:主人段第二人称 + 阻止内部裸键泄漏 (#1569)
* 改名记录修补:主人段改用第二人称,并阻止内部裸键泄漏进 persona fact 承接 #1538,修两处改名事件拼装进上下文时的问题: 1. 主人档案的改名记录落在猫娘 persona 的 master section(读者是猫娘、改名 的是用户),原先复用第一人称模板会让猫娘把用户的改名误当成自己改名。新增 PROFILE_RENAME_EVENT_*_MASTER 第二人称模板(8 语言),_build_ai_context_fields / _build_effective_character_payload / render_profile_rename_event_context 增 entity 参数,主人侧传 entity="master"。第二人称同时避开「主人/master」物化称呼。 2. persona card 同步对合成字段 __ai_context.* 仍按 "{k}: {v}" 拼接,导致内部裸键 __ai_context.profile_rename_events 原样泄漏进模型读到的 fact。改为这类字段直出 value(已自带本地化标签),不再前缀内部键。 更新两条原断言旧行为的回归测试,并补主人第二人称的单测。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * 主人改名记录改为去人称中性表述;__ai_context 前缀收敛到带点匹配 - 主人段改名记录原用第二人称「你」,读着别扭;改为去掉人称的中性陈述 ("档案名已从「旧」改为「新」,当前名字是「新」。"),和 master section 里 昵称/性别等无人称字段语气对齐。猫娘自身(neko)仍保留第一人称。 - persona 同步判定合成字段的前缀由 "__ai_context" 收敛为 "__ai_context.", 避免误伤 __ai_contextual_* 这类普通键(CodeRabbit 指出)。 - 同步更新相关单测与回归断言为中性表述。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * 改名记录文案去掉「档案名」内部术语,改用「曾用名」等日常说法 「档案名」是内部配置字段名,从不渲染、AI 也对不上号,写进给模型读的文案里属 于无意义术语。neko/master 两套模板统一改成「曾用名/现在改名为」「曾用名/现在的 名字是」这类常规表述(8 语言),同步更新断言。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 57f236a commit 134eef6

4 files changed

Lines changed: 136 additions & 27 deletions

File tree

config/prompts/prompts_memory.py

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,14 +1093,40 @@ def split_inner_thoughts_and_history(text: str) -> tuple[str, str] | None:
10931093
}
10941094

10951095
PROFILE_RENAME_EVENT_TEXT = {
1096-
"zh": "我以前的档案名是「{old_name}」,现在已经改名为「{new_name}」。以后请把「{new_name}」当作我的当前名字。",
1097-
"zh-TW": "我以前的檔案名是「{old_name}」,現在已經改名為「{new_name}」。以後請把「{new_name}」當作我的目前名字。",
1098-
"en": "My previous profile name was \"{old_name}\"; I have now changed it to \"{new_name}\". Treat \"{new_name}\" as my current name from now on.",
1099-
"ja": "以前の私のプロフィール名は「{old_name}」で、今は「{new_name}」に改名しました。これからは「{new_name}」を私の現在の名前として扱ってください。",
1100-
"ko": "내 이전 프로필 이름은 \"{old_name}\"였고, 지금은 \"{new_name}\"으로 바뀌었습니다. 앞으로는 \"{new_name}\"을 내 현재 이름으로 여기세요.",
1101-
"ru": "Раньше моё имя профиля было «{old_name}», теперь я сменила его на «{new_name}». С этого момента считай «{new_name}» моим текущим именем.",
1102-
"es": "Mi nombre de perfil anterior era \"{old_name}\"; ahora lo he cambiado a \"{new_name}\". A partir de ahora, trata \"{new_name}\" como mi nombre actual.",
1103-
"pt": "Meu nome de perfil anterior era \"{old_name}\"; agora mudei para \"{new_name}\". A partir de agora, trate \"{new_name}\" como meu nome atual.",
1096+
"zh": "我曾用名「{old_name}」,现在已经改名为「{new_name}」。以后请把「{new_name}」当作我的当前名字。",
1097+
"zh-TW": "我曾用名「{old_name}」,現在已經改名為「{new_name}」。以後請把「{new_name}」當作我的目前名字。",
1098+
"en": "I was formerly known as \"{old_name}\"; I am now called \"{new_name}\". Treat \"{new_name}\" as my current name from now on.",
1099+
"ja": "私はかつて「{old_name}」と呼ばれていましたが、今は「{new_name}」に改名しました。これからは「{new_name}」を私の現在の名前として扱ってください。",
1100+
"ko": "나는 예전에 \"{old_name}\"(으)로 불렸지만, 지금은 \"{new_name}\"(으)로 이름을 바꿨습니다. 앞으로는 \"{new_name}\"을 내 현재 이름으로 여기세요.",
1101+
"ru": "Раньше меня звали «{old_name}», теперь я сменила имя на «{new_name}». С этого момента считай «{new_name}» моим текущим именем.",
1102+
"es": "Antes me llamaba \"{old_name}\"; ahora me llamo \"{new_name}\". Trata \"{new_name}\" como mi nombre actual de ahora en adelante.",
1103+
"pt": "Antes eu me chamava \"{old_name}\"; agora me chamo \"{new_name}\". Trate \"{new_name}\" como meu nome atual de agora em diante.",
1104+
}
1105+
1106+
# 主人档案的改名记录走在猫娘(AI)的 persona/master section 里——读这段的是猫娘,
1107+
# 改名的是对面的用户。第一人称「我」会让猫娘以为是自己改了名,第二人称「你」又读着
1108+
# 别扭,所以这里**去掉人称**,用中性陈述,和 master section 里其它无人称字段(昵称/
1109+
# 性别…)的语气对齐。
1110+
PROFILE_RENAME_EVENT_FIELD_MASTER = {
1111+
"zh": "改名记录",
1112+
"zh-TW": "改名紀錄",
1113+
"en": "Profile Rename Record",
1114+
"ja": "改名記録",
1115+
"ko": "프로필 이름 변경 기록",
1116+
"ru": "Запись о смене имени профиля",
1117+
"es": "Registro de cambio de nombre de perfil",
1118+
"pt": "Registro de mudança de nome do perfil",
1119+
}
1120+
1121+
PROFILE_RENAME_EVENT_TEXT_MASTER = {
1122+
"zh": "曾用名「{old_name}」,现在的名字是「{new_name}」。",
1123+
"zh-TW": "曾用名「{old_name}」,現在的名字是「{new_name}」。",
1124+
"en": "Formerly known as \"{old_name}\"; the current name is \"{new_name}\".",
1125+
"ja": "かつての名前は「{old_name}」で、現在の名前は「{new_name}」です。",
1126+
"ko": "예전 이름은 \"{old_name}\"였고, 현재 이름은 \"{new_name}\"입니다.",
1127+
"ru": "Прежнее имя — «{old_name}», текущее имя — «{new_name}».",
1128+
"es": "Nombre anterior: \"{old_name}\"; nombre actual: \"{new_name}\".",
1129+
"pt": "Nome anterior: \"{old_name}\"; nome atual: \"{new_name}\".",
11041130
}
11051131

11061132

@@ -1128,12 +1154,22 @@ def render_profile_rename_event_context(
11281154
lang: str | None,
11291155
old_name: str,
11301156
new_name: str,
1157+
entity: str = "neko",
11311158
) -> tuple[str, str]:
1132-
"""渲染给 AI 的一人称改名记录,返回 (字段名, 内容)。"""
1159+
"""渲染改名记录,返回 (字段名, 内容)。
1160+
1161+
entity="neko":写进猫娘自己的 section,用第一人称「我」。
1162+
entity="master":写进猫娘 persona 的 master section,读者是猫娘、改名的是用户,
1163+
因此去掉人称用中性陈述,避免第一人称把用户的改名误当成猫娘自己的。
1164+
"""
11331165
lang_key = _normalize_memory_prompt_lang(lang)
1166+
if str(entity or "").strip().lower() == "master":
1167+
field_dict, text_dict = PROFILE_RENAME_EVENT_FIELD_MASTER, PROFILE_RENAME_EVENT_TEXT_MASTER
1168+
else:
1169+
field_dict, text_dict = PROFILE_RENAME_EVENT_FIELD, PROFILE_RENAME_EVENT_TEXT
11341170
return (
1135-
_loc(PROFILE_RENAME_EVENT_FIELD, lang_key),
1136-
_loc(PROFILE_RENAME_EVENT_TEXT, lang_key).format(
1171+
_loc(field_dict, lang_key),
1172+
_loc(text_dict, lang_key).format(
11371173
old_name=str(old_name or "").strip(),
11381174
new_name=str(new_name or "").strip(),
11391175
),

memory/persona.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,14 @@ def _build_expected(card_data: dict, entity: str) -> list[tuple[str, dict]]:
521521
if isinstance(v, list):
522522
v = '、'.join(str(item) for item in v)
523523
entry_id = self._card_entry_id(entity, k)
524-
text = f"{k}: {v}"
524+
if str(k).startswith("__ai_context."):
525+
# 合成运行时上下文字段(如 __ai_context.profile_rename_events):
526+
# value 已是自带本地化标签的完整句子,不能再前缀内部键名,
527+
# 否则裸键 "__ai_context.xxx: ..." 会原样泄漏进模型读到的 fact。
528+
# 用带点的前缀精确匹配约定命名,避免误伤 __ai_contextual_* 这类普通键。
529+
text = str(v)
530+
else:
531+
text = f"{k}: {v}"
525532
expected.append((entry_id, text))
526533
return expected
527534

tests/unit/test_character_memory_regression.py

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -107,19 +107,73 @@ def test_profile_rename_event_prompt_i18n_is_complete_and_first_person():
107107

108108
zh_label, zh_text = render_profile_rename_event_context("zh-CN", "旧角色", "新角色")
109109
assert zh_label == "我的改名记录"
110-
assert "我以前的档案名" in zh_text
110+
assert "我曾用名" in zh_text
111111
assert "旧角色" in zh_text
112112
assert "新角色" in zh_text
113113
assert "只代表改名前的历史称呼" not in zh_text
114114

115115
en_label, en_text = render_profile_rename_event_context("en", "Old", "New")
116116
assert en_label == "My Profile Rename Record"
117-
assert "My previous profile name" in en_text
117+
assert "formerly known as" in en_text
118118
assert "Old" in en_text
119119
assert "New" in en_text
120120
assert "historical name before the rename" not in en_text
121121

122122

123+
@pytest.mark.unit
124+
def test_profile_rename_event_master_is_person_neutral():
125+
"""主人改名记录进的是猫娘 persona 的 master section,读者是猫娘、
126+
改名的是用户。第一人称会让猫娘误以为是自己改名,所以这里去掉人称、
127+
用中性陈述,既不能出现「我」也不带「你」。"""
128+
from config.prompts.prompts_memory import (
129+
PROFILE_RENAME_EVENT_FIELD_MASTER,
130+
PROFILE_RENAME_EVENT_TEXT_MASTER,
131+
render_profile_rename_event_context,
132+
)
133+
134+
expected_langs = {"zh", "zh-TW", "en", "ja", "ko", "ru", "es", "pt"}
135+
assert set(PROFILE_RENAME_EVENT_FIELD_MASTER) == expected_langs
136+
assert set(PROFILE_RENAME_EVENT_TEXT_MASTER) == expected_langs
137+
138+
zh_label, zh_text = render_profile_rename_event_context("zh-CN", "旧名", "新名", entity="master")
139+
assert zh_label == "改名记录"
140+
assert "旧名" in zh_text and "新名" in zh_text
141+
# 去人称:既无第一人称「我」也无第二人称「你」。
142+
assert "我" not in zh_text
143+
assert "你" not in zh_text
144+
145+
en_label, en_text = render_profile_rename_event_context("en", "Old", "New", entity="master")
146+
assert en_label == "Profile Rename Record"
147+
assert "Old" in en_text and "New" in en_text
148+
assert "My " not in en_text and "Your " not in en_text
149+
150+
# 缺省(neko)仍是第一人称,主人变体不影响默认行为。
151+
_, neko_text = render_profile_rename_event_context("zh-CN", "旧名", "新名")
152+
assert "我曾用名" in neko_text
153+
154+
155+
@pytest.mark.unit
156+
def test_master_effective_payload_rename_context_is_person_neutral(monkeypatch):
157+
monkeypatch.setattr("utils.language_utils.get_global_language_full", lambda: "zh-CN")
158+
from utils.config_manager import _build_effective_character_payload
159+
160+
payload = {
161+
"档案名": "新主人名",
162+
"_reserved": {
163+
"ai_context": {
164+
"rename_events": [
165+
{"type": "profile_rename", "old_name": "旧主人名", "new_name": "新主人名"},
166+
]
167+
}
168+
},
169+
}
170+
effective = _build_effective_character_payload(payload, entity="master")
171+
context = effective["__ai_context.profile_rename_events"]
172+
assert "旧主人名" in context and "新主人名" in context
173+
assert "我" not in context
174+
assert "你" not in context
175+
176+
123177
@pytest.mark.unit
124178
def test_profile_rename_event_uses_collision_safe_synthetic_key(monkeypatch):
125179
monkeypatch.setattr("utils.language_utils.get_global_language_full", lambda: "zh-CN")
@@ -150,7 +204,7 @@ def test_profile_rename_event_uses_collision_safe_synthetic_key(monkeypatch):
150204
assert effective["我的改名记录"] == "用户自己写的字段"
151205
hidden_context = effective["__ai_context.profile_rename_events"]
152206
assert "我的改名记录" in hidden_context
153-
assert "我以前的档案名" in hidden_context
207+
assert "我曾用名" in hidden_context
154208
assert "旧角色" in hidden_context
155209
assert "临时角色" in hidden_context
156210
assert "新角色" in hidden_context
@@ -164,7 +218,7 @@ def test_profile_rename_event_uses_collision_safe_synthetic_key(monkeypatch):
164218
for key, value in effective_with_internal_collision.items()
165219
if key.startswith("__ai_context.profile_rename_events.")
166220
]
167-
assert any("我以前的档案名" in str(value) for value in collision_values)
221+
assert any("我曾用名" in str(value) for value in collision_values)
168222

169223

170224
@pytest.mark.unit
@@ -436,14 +490,15 @@ async def _noop_any(*args, **kwargs):
436490
_, _, _, effective_character_data, _, _, _, _, _ = cm.get_character_data()
437491
hidden_context = effective_character_data["新角色"]["__ai_context.profile_rename_events"]
438492
assert "我的改名记录" in hidden_context
439-
assert "我以前的档案名" in hidden_context
493+
assert "我曾用名" in hidden_context
440494
assert "旧角色" in hidden_context
441495
assert "新角色" in hidden_context
442496
from memory.persona import PersonaManager
443497
persona_md = PersonaManager().render_persona_markdown("新角色")
444-
assert "__ai_context.profile_rename_events" in persona_md
498+
# 合成字段的内部裸键不能泄漏进渲染给模型的 persona 文本,只保留本地化标签。
499+
assert "__ai_context.profile_rename_events" not in persona_md
445500
assert "我的改名记录" in persona_md
446-
assert "我以前的档案名" in persona_md
501+
assert "我曾用名" in persona_md
447502
assert "旧角色" in persona_md
448503
assert "新角色" in persona_md
449504
assert not (Path(cm.memory_dir) / "旧角色").exists()
@@ -509,15 +564,19 @@ async def _noop_any(*args, **kwargs):
509564

510565
_, _, master_basic_config, _, _, _, _, _, _ = cm.get_character_data()
511566
hidden_context = master_basic_config["__ai_context.profile_rename_events"]
512-
assert "我的改名记录" in hidden_context
513-
assert "我以前的档案名" in hidden_context
567+
# 主人改名记录进的是猫娘 persona 的 master section,去掉人称用中性陈述,
568+
# 既不能第一人称「我」(否则猫娘会以为是自己改了名),也不带第二人称「你」。
569+
assert "改名记录" in hidden_context
570+
assert "我" not in hidden_context
571+
assert "你" not in hidden_context
514572
assert old_master_name in hidden_context
515573
assert "新主人" in hidden_context
516574

517575
from memory.persona import PersonaManager
518576
persona_md = PersonaManager().render_persona_markdown(current_catgirl)
519-
assert "__ai_context.profile_rename_events" in persona_md
520-
assert "我的改名记录" in persona_md
577+
# 同上:裸键不泄漏,且主人段无人称。
578+
assert "__ai_context.profile_rename_events" not in persona_md
579+
assert "改名记录" in persona_md
521580
assert old_master_name in persona_md
522581
assert "新主人" in persona_md
523582

utils/config_manager.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ def _get_persona_override(character_payload: dict) -> dict | None:
296296
return override
297297

298298

299-
def _build_effective_character_payload(character_payload: dict) -> dict:
299+
def _build_effective_character_payload(character_payload: dict, entity: str = "neko") -> dict:
300300
if not isinstance(character_payload, dict):
301301
return {}
302302

@@ -306,6 +306,7 @@ def _build_effective_character_payload(character_payload: dict) -> dict:
306306
for field, value in _build_ai_context_fields(
307307
character_payload,
308308
existing_fields=set(effective_payload.keys()),
309+
entity=entity,
309310
).items():
310311
effective_payload[field] = value
311312
return effective_payload
@@ -316,6 +317,7 @@ def _build_effective_character_payload(character_payload: dict) -> dict:
316317
for field, value in _build_ai_context_fields(
317318
character_payload,
318319
existing_fields=set(effective_payload.keys()),
320+
entity=entity,
319321
).items():
320322
effective_payload[field] = value
321323
return effective_payload
@@ -380,8 +382,13 @@ def _join_profile_rename_old_names(lang: str | None, names: list[str]) -> str:
380382
def _build_ai_context_fields(
381383
character_payload: dict,
382384
existing_fields: set[str] | None = None,
385+
entity: str = "neko",
383386
) -> dict[str, str]:
384-
"""把隐藏运行时事件展开成只给 prompt/记忆同步使用的合成字段。"""
387+
"""把隐藏运行时事件展开成只给 prompt/记忆同步使用的合成字段。
388+
389+
entity 区分这份 payload 是猫娘(neko)还是主人(master),决定改名记录的人称:
390+
主人的记录进的是猫娘 persona 的 master section,必须第二人称,不能第一人称。
391+
"""
385392
if not isinstance(character_payload, dict):
386393
return {}
387394

@@ -429,7 +436,7 @@ def _build_ai_context_fields(
429436
lines: list[str] = []
430437
if old_names and current_name:
431438
old_names_text = _join_profile_rename_old_names(lang, old_names)
432-
label, text = render_profile_rename_event_context(lang, old_names_text, current_name)
439+
label, text = render_profile_rename_event_context(lang, old_names_text, current_name, entity=entity)
433440
lines.append(f"{label}: {text}")
434441

435442
lines.extend(legacy_lines)
@@ -2872,7 +2879,7 @@ def get_character_data(self):
28722879
character_data.setdefault('主人', deepcopy(defaults['主人']))
28732880
character_data.setdefault('猫娘', deepcopy(defaults['猫娘']))
28742881

2875-
master_basic_config = _build_effective_character_payload(character_data.get('主人', {}))
2882+
master_basic_config = _build_effective_character_payload(character_data.get('主人', {}), entity="master")
28762883
master_name = master_basic_config.get('档案名', defaults['主人']['档案名'])
28772884

28782885
raw_character_data = character_data.get('猫娘') or deepcopy(defaults['猫娘'])

0 commit comments

Comments
 (0)