Skip to content

Commit c016882

Browse files
KrabbypattylHongzhi Wenclaude
authored
修复主动搭话屏幕标签泄漏 (Project-N-E-K-O#1556)
* 修复主动搭话屏幕标签泄漏 主动搭话屏幕-only 场景下,模型可能把屏幕来源误写成 [Screen] 首行标签,并被当作正文发送。 新增屏幕来源标签清理逻辑,剥离 [Screen]、[Vision]、[Window] 等非法首行标签,同时保留 [CHAT]、[WEB]、[MUSIC]、[MEME]、[PASS] 等合法标签解析。 在流式解析、flush、格式纠正、BM25 regen 和最终投递前统一兜底清理,并增加回归测试覆盖 [Screen] 泄漏与合法标签保留。 * fix(proactive): 屏幕泄漏标签归一成 CHAT 并兼容组合标签 _strip_proactive_screen_tag_leak 原先只删 [Screen] 不设 source_tag, 导致剥完后落到无 tag 的格式自救 regen/drop 路径。现改为返回 (cleaned, recovered_tag):已知屏幕泄漏标签一律归一成 CHAT(语义上 就是聊屏幕内容),并兼容 [Screen][CHAT] 这类组合(剥掉泄漏标签后 采用紧随的真实来源标签,避免 [CHAT] 字面漏给 TTS)。匹配大小写不敏感。 5 处调用点(流式 / flush / 格式自救 / 最终兜底 / BM25 regen)同步 消费 recovered tag。回归测试补 CHAT 映射、大小写、组合标签、未知标签放行。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e67374c commit c016882

2 files changed

Lines changed: 100 additions & 0 deletions

File tree

main_routers/system_router.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,6 +1726,46 @@ def _parse_unified_phase1_result(text: str) -> dict:
17261726
return result
17271727

17281728

1729+
_PROACTIVE_LEGAL_SOURCE_TAGS = frozenset({"CHAT", "WEB", "PASS", "MUSIC", "MEME"})
1730+
_PROACTIVE_SCREEN_TAG_LEAKS = frozenset({"SCREEN", "SCREENSHOT", "VISION", "WINDOW"})
1731+
_PROACTIVE_BRACKET_TAG_RE = re.compile(r"^\[([A-Za-z][A-Za-z0-9_-]{0,31})\]\s*")
1732+
_PROACTIVE_LEGAL_TAG_RE = re.compile(r"^\[(CHAT|WEB|PASS|MUSIC|MEME)\]\s*", re.IGNORECASE)
1733+
1734+
1735+
def _strip_proactive_screen_tag_leak(text: str) -> tuple[str, str]:
1736+
"""剥离 Phase 2 文本里误写的屏幕来源标签(如 ``[Screen]``)。
1737+
1738+
主动搭话屏幕-only 场景下模型偶尔把屏幕来源当成首行标签吐出来。这类标签语义上
1739+
就是"在聊屏幕里看到的东西"= 普通搭话,统一归一成 ``CHAT``。
1740+
1741+
返回 ``(cleaned_text, recovered_source_tag)``:
1742+
- 命中已知屏幕泄漏标签 → 剥掉它。若其后紧跟合法来源标签(``[Screen][CHAT]``
1743+
这类组合)则一并剥掉并采用该真实 tag,否则按 ``CHAT`` 兜底。
1744+
- 未命中(无标签 / 合法标签 / 未知标签)→ 原样返回,recovered 为空串,
1745+
交回调用方既有的无 tag 处理(格式自救 regen / drop)。
1746+
1747+
标签匹配大小写不敏感。
1748+
"""
1749+
if not text:
1750+
return "", ""
1751+
leading_len = len(text) - len(text.lstrip())
1752+
leading = text[:leading_len]
1753+
body = text[leading_len:]
1754+
match = _PROACTIVE_BRACKET_TAG_RE.match(body)
1755+
if not match:
1756+
return text, ""
1757+
tag = match.group(1).upper()
1758+
if tag in _PROACTIVE_LEGAL_SOURCE_TAGS or tag not in _PROACTIVE_SCREEN_TAG_LEAKS:
1759+
return text, ""
1760+
rest = body[match.end():].lstrip()
1761+
# 兼容 [Screen][CHAT] 组合:泄漏标签后若紧跟合法来源标签,剥掉并采用真实 tag
1762+
# (否则该 [CHAT] 字面会作为正文漏给 TTS);没有则按 CHAT 兜底。
1763+
legal = _PROACTIVE_LEGAL_TAG_RE.match(rest)
1764+
if legal:
1765+
return leading + rest[legal.end():].lstrip(), legal.group(1).upper()
1766+
return leading + rest, "CHAT"
1767+
1768+
17291769
def _lookup_link_by_title(title: str, all_links: list[dict]) -> dict | None:
17301770
"""
17311771
根据 Phase 1 输出的标题在 all_web_links 中查找对应链接
@@ -6319,6 +6359,10 @@ async def _emit_safe(text: str) -> bool:
63196359
if tag_match:
63206360
source_tag = tag_match.group(1).upper()
63216361
cleaned = cleaned[tag_match.end():]
6362+
else:
6363+
cleaned, _leak_tag = _strip_proactive_screen_tag_leak(cleaned)
6364+
if _leak_tag:
6365+
source_tag = _leak_tag
63226366
tag_parsed = True
63236367

63246368
if source_tag == 'PASS' or '[PASS]' in cleaned.upper():
@@ -6375,6 +6419,10 @@ async def _emit_safe(text: str) -> bool:
63756419
if tag_match:
63766420
source_tag = tag_match.group(1).upper()
63776421
cleaned = cleaned[tag_match.end():]
6422+
else:
6423+
cleaned, _leak_tag = _strip_proactive_screen_tag_leak(cleaned)
6424+
if _leak_tag:
6425+
source_tag = _leak_tag
63786426
if source_tag == 'PASS' or '[PASS]' in cleaned.upper():
63796427
aborted = True
63806428
elif cleaned.strip():
@@ -6431,6 +6479,10 @@ async def _emit_safe(text: str) -> bool:
64316479
if _ftm:
64326480
_fix_tag = _ftm.group(1).upper()
64336481
_fc = _fc[_ftm.end():]
6482+
else:
6483+
_fc, _leak_tag = _strip_proactive_screen_tag_leak(_fc)
6484+
if _leak_tag:
6485+
_fix_tag = _leak_tag
64346486
if _fix_tag and _fix_tag != "PASS" and _fc.strip() and "[PASS]" not in _fc.upper():
64356487
source_tag = _fix_tag
64366488
full_text = _fc.strip()
@@ -6460,6 +6512,9 @@ async def _emit_safe(text: str) -> bool:
64606512
"message": "Phase 2 流式输出被拦截或为空"
64616513
}))
64626514

6515+
full_text, _leak_tag = _strip_proactive_screen_tag_leak(full_text)
6516+
if _leak_tag and not source_tag:
6517+
source_tag = _leak_tag
64636518
response_text = full_text.strip()
64646519
# 不要把 proactive 原文写进 logger(会进日志文件 / 遥测);只记元数据。
64656520
# 完整原文通过 print 给开发者本地查看。
@@ -6601,6 +6656,10 @@ async def _emit_safe(text: str) -> bool:
66016656
if _tag_m:
66026657
regen_source_tag = _tag_m.group(1).upper()
66036658
_cleaned = _cleaned[_tag_m.end():]
6659+
else:
6660+
_cleaned, _leak_tag = _strip_proactive_screen_tag_leak(_cleaned)
6661+
if _leak_tag:
6662+
regen_source_tag = _leak_tag
66046663
# regen 输出 [PASS] / 空 → 等价于"模型放弃了",drop 而不是退回原文。
66056664
# 显式把 ``regen_source_tag == 'PASS'`` 也算 drop(前面剥过 [TAG] 前缀,
66066665
# _cleaned 已不含字面 "[PASS]",但 regen_source_tag 记下了是 PASS)。

tests/unit/test_proactive_phase1_pass.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,47 @@ def test_parse_unified_phase1_keyword_plus_pass_template_line_is_not_pass():
6666
assert parsed["music_pass"] is False
6767

6868

69+
def test_strip_proactive_screen_tag_leak_removes_screen_source_label():
70+
cleaned, tag = sr._strip_proactive_screen_tag_leak(
71+
"[Screen]\n看这满屏的符咒,是在给那画中仙重塑筋骨?"
72+
)
73+
74+
assert cleaned == "看这满屏的符咒,是在给那画中仙重塑筋骨?"
75+
# 已知泄漏标签统一归一成 CHAT,下游按普通搭话投递(不再误判无 tag 走 regen/drop)
76+
assert tag == "CHAT"
77+
78+
79+
def test_strip_proactive_screen_tag_leak_is_case_insensitive():
80+
for raw in ("[SCREEN]", "[screen]", "[ScReEn]", "[Vision]", "[window]"):
81+
cleaned, tag = sr._strip_proactive_screen_tag_leak(f"{raw} 你好呀")
82+
assert cleaned == "你好呀"
83+
assert tag == "CHAT"
84+
85+
86+
def test_strip_proactive_screen_tag_leak_recovers_combined_legal_tag():
87+
# [Screen][CHAT] 组合:剥掉泄漏标签后采用紧随其后的真实来源标签,
88+
# 避免 [CHAT] 字面作为正文漏给 TTS。
89+
cleaned, tag = sr._strip_proactive_screen_tag_leak("[Screen][WEB]\n看这个链接")
90+
91+
assert cleaned == "看这个链接"
92+
assert tag == "WEB"
93+
94+
95+
def test_strip_proactive_screen_tag_leak_preserves_legal_source_tags():
96+
cleaned, tag = sr._strip_proactive_screen_tag_leak("[CHAT]\n你好呀")
97+
98+
assert cleaned == "[CHAT]\n你好呀"
99+
assert tag == ""
100+
101+
102+
def test_strip_proactive_screen_tag_leak_ignores_unknown_bracket_tags():
103+
# 未知 / 非屏幕泄漏标签保守放行,留给调用方既有的无 tag 处理逻辑。
104+
cleaned, tag = sr._strip_proactive_screen_tag_leak("[Foo] 这不是来源标签")
105+
106+
assert cleaned == "[Foo] 这不是来源标签"
107+
assert tag == ""
108+
109+
69110
def test_recent_proactive_prompt_has_strong_paired_boundaries():
70111
lanlan = "测试娘"
71112
snapshot = sr._proactive_chat_history.get(lanlan)

0 commit comments

Comments
 (0)