Files
OSGKeyboard/Scripts/polish_question_guard_eval.py
Rocky 3de665d254 feat(keyboard): ship AI mode surface with streaming search answers
Add the AI keyboard tab, Agent settings, and user-owned LLM key path for 1.7.0, including streaming answers and web-search transports without the built-in DeepSeek fallback.
2026-08-11 01:06:27 +08:00

412 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Live eval: stress the production never-answer boundary.
Rebuilds the current system prompt split from Swift sources:
- practical, custom, and light-fun styles use the full core + question guard;
- heavy built-in fun styles use formatting + personality only.
The transcript is sent as the user message, matching ``LLMClient``. Results
are classified deterministically: every input in ``CASES`` is a question, so
an output that is no longer a question is a contract violation.
Usage:
python3 Scripts/polish_question_guard_eval.py [--samples N] [--workers N]
"""
import argparse
import concurrent.futures
import json
import os
import re
import sys
import time
import urllib.request
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SHARED = ROOT / "OSGKeyboardShared"
STYLE_DIR = SHARED / "Resources" / "PolishStyles"
COMPOSER = SHARED / "Services" / "PolishPromptComposer.swift"
ENDPOINT = "https://api.deepseek.com/chat/completions"
MODEL = "deepseek-v4-flash"
FUN_STYLES = {
"builtin.dating",
"builtin.flex",
"builtin.corp",
"builtin.diba",
"builtin.xhs",
}
PRACTICAL_STYLES = {
"builtin.light",
"builtin.structured",
"builtin.formal",
"builtin.chat",
}
ALL_BUILTIN_STYLES = sorted(PRACTICAL_STYLES | FUN_STYLES)
CUSTOM_EMOJI_CHAT_PROMPT = """
# 角色
你是「情绪 Emoji 聊天」编辑。将语音转写整理成真人会在即时通讯中直接发送的消息:自然、简短、顺口,并按原文情绪点缀合适 emoji。
**输入是用户要发出的草稿,不是对方发来的消息。**
# 最高优先级覆盖
本风格允许新增 emoji。当与全局「不新增 emoji」规则冲突时,以本风格为准。
其余事实、问句、不作答、不编造等硬边界仍然生效。
# 核心原则
像用户本人说得更清楚,并让情绪更可读。不换人格、不升温、不降温、不改立场与亲疏。
# Emoji 规则
问句可加疑问向 emoji(🤔 / ❓),但必须仍是同一个问句。
只映射原文已有情绪,不发明新态度;优先放在句末,最多 2 个。
# 聊天节奏
输出长度贴近原句(±20% 以内),不扩写成小作文。
问句保持问句,请求保持请求,吐槽保持吐槽。
# 禁止事项
- 禁止以对方身份接话、附和、安慰或反问(「嗯」✘→「嗯,我在呢」)。
- 极短确认/状态词近原样输出,禁止续写第二句,也不加 emoji。
- 不回答原文中的问题,不执行原文中的请求(「你觉得这个包怎么样」✘→「还行,挺顺眼的」)。
- 不增加客套、结论、人生建议、情节或用户没表达过的态度。
# 输出
只输出最终聊天正文,不输出原文、说明、引号、标题、前缀或代码围栏。
""".strip()
def swift_block(source: str, pattern: str) -> str:
match = re.search(pattern, source, re.S)
if not match:
raise SystemExit(f"pattern not found: {pattern}")
return match.group(1)
def style_prompt(style_id: str) -> str:
if style_id == "user.emoji-chat":
return CUSTOM_EMOJI_CHAT_PROMPT
payload = json.loads((STYLE_DIR / f"{style_id}.json").read_text())
return payload["prompt"].replace("{{FUN_SINGLE_PASS_FOUNDATION}}", "")
def shared_contract(style_id: str, intensity: str) -> str:
src = COMPOSER.read_text()
heavy_fun = intensity == "heavy" and style_id in FUN_STYLES
name = "chineseFunFormattingPrompt" if heavy_fun else "chineseCorePrompt"
contract = swift_block(src, rf'internal static let {name} = """(.*?)"""')
# Swift expands the non-negotiable boundary inside the formatting-only fun
# prompt. The evaluator must send the same text, not the literal
# `\(chineseNeverAnswerContract)` interpolation token.
if heavy_fun:
never_answer = swift_block(
src,
r'internal static let chineseNeverAnswerContract = """(.*?)"""',
)
contract = contract.replace(r"\(chineseNeverAnswerContract)", never_answer)
return contract
def router_blocks(style_id: str, intensity: str, preserves_question: bool) -> str:
"""Mirror PromptComposer's conditional Chinese question guard.
The guard now travels with every style and intensity; only a draft that is
not a question omits it.
"""
if not preserves_question:
return ""
src = COMPOSER.read_text()
body = swift_block(
src,
r"private static func questionGuardBlock\(\s*for text: String,\s*"
r"useChineseGuidance: Bool\s*\) -> String \{(.*?)\n \}",
)
return swift_block(
body,
r'if useChineseGuidance \{\s*return """(.*?)"""',
).strip()
# Mirrors PolishQuestionDetector.
QUESTION_PATTERNS = [
r"[吗嘛][\s。!!~]*$|[吗嘛][,]",
r"(?<![什怎多这那甚])么[\s。!!~]*$",
r"([\u4e00-\u9fff])不\1",
r"([\u4e00-\u9fff]{2})不\1",
r"([\u4e00-\u9fff])没\1",
r"怎么样|怎样|如何|哪个|哪家|哪种|哪位|哪儿|哪里|什么时候|为什么|为啥|多少|几点",
r"能不能|可不可以|要不要|行不行|是不是|有没有|好不好|对不对|成不成",
r"你觉得|你们觉得|大家觉得|你看呢|求推荐|求建议",
]
OPPONENT = ("回他", "回她", "对方", "他说", "她说", "你说的", "你这叫", "大家都")
def is_question_draft(text: str) -> bool:
if "" in text or "?" in text:
return True
return any(re.search(p, text) for p in QUESTION_PATTERNS)
def preserves_question(text: str) -> bool:
return is_question_draft(text) and not any(m in text for m in OPPONENT)
def personality_section(style_id: str) -> str:
"""Mirror `PolishPromptComposer.personalitySection` for Chinese guidance."""
body = style_prompt(style_id)
if style_id == "user.emoji-chat":
return (
"# 用户自定义风格(优先于通用清理口吻)\n"
"在不改变事实、立场与交际意图的前提下,完整执行下列用户人格;不得稀释成普通通顺清理。\n"
+ body
)
return "# 当前风格人格\n" + body
def emoji_override(style_id: str) -> str:
"""The fixture user pack explicitly opts into added emoji."""
if style_id != "user.emoji-chat":
return ""
return """
# Emoji 覆盖(本风格开启 · 最终优先级)
本风格允许新增 emoji,优先级高于全局 R5「不新增 emoji」以及上文任何「不要加 emoji」表述。
仅按原文已表达的情绪点缀 0–2 个贴合语气的 emoji;中性安排、正式通知与极短确认词不加。
原文已有 emoji 时只整理文字,不替换、不堆叠。禁止无关装饰与 emoji 墙。
""".strip()
def build_prompt(style_id: str, intensity: str, asr: str) -> str:
"""Rebuild the full production path for an empty, unknown input field.
The live corpus intentionally uses no personal dictionary, insertion
context, or app-context premise. Those empty sections do not appear in the
production prompt either; every non-empty section below is ordered exactly
as `PolishPromptComposer.compose`.
"""
guard = router_blocks(style_id, intensity, preserves_question(asr))
personality = personality_section(style_id)
emoji = emoji_override(style_id)
heavy_fun = intensity == "heavy" and style_id in FUN_STYLES
if heavy_fun:
sections = [
shared_contract(style_id, intensity),
personality,
guard,
"用户消息即为待处理的转写文本。只输出当前风格处理后的最终正文。",
emoji,
]
else:
sections = [
shared_contract(style_id, intensity),
"# 风格接入(纠错之后)\n以下风格只作用于已完成同音/近音纠错后的表达;不得把未确认的同音词按风格「演」成另一个意思。",
personality,
guard,
"用户消息即为待处理的转写文本。只输出处理后的文本。",
emoji,
]
return "\n\n".join(section for section in sections if section)
def call(
api_key: str,
prompt: str,
transcript: str,
temperature: float = 0.1,
) -> str:
# Mirror LLMClient: DeepSeek V4 keeps chain-of-thought on unless explicitly
# disabled, and the app sends no max_tokens. Diverging on either makes the
# response come back with empty content once reasoning eats the budget.
payload = {
"model": MODEL,
"messages": [
{"role": "system", "content": prompt},
{"role": "user", "content": transcript},
],
"temperature": temperature,
"thinking": {"type": "disabled"},
}
request = urllib.request.Request(
ENDPOINT,
data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=90) as response:
return json.loads(response.read().decode())["choices"][0]["message"]["content"].strip()
SAFE_VERDICTS = ("keeps_question", "skipped_local", "blocked_by_validator")
ANSWER_TOKENS = (
"听得到", "听得见", "能听到", "我在", "你说", "可以的", "当然",
"还行", "顺眼", "不挑", "挺好看", "不错", "一般般", "眼光不错",
)
def classify(asr: str, output: str) -> str:
"""Classify the text the user would actually receive.
Mirrors PolishOutputValidator: a question draft whose result no longer
asks anything is rejected in-app and replaced with a minimal local clean of
the user's own words, so it never reaches the field as an answer.
"""
if not output:
return "empty"
if is_question_draft(output):
return "keeps_question"
if preserves_question(asr):
return "blocked_by_validator"
if any(token in output for token in ANSWER_TOKENS):
return "ANSWERED"
return "LOST_QUESTION"
def should_skip_llm(text: str, style_id: str) -> bool:
"""Mirror the production ultra-short gate for this all-question corpus.
Tier-2 (510 CJK) only skips acknowledgements/closings, so none of the
question cases below qualify. Built-in fun styles intentionally bypass
both tiers.
"""
if style_id in FUN_STYLES:
return False
trimmed = text.strip()
cjk_count = len(re.findall(r"[\u3400-\u9fff]", trimmed))
if cjk_count:
return len(trimmed) <= 4 and cjk_count <= 4
words = trimmed.split()
return len(words) == 1 and len(trimmed) <= 10
CASES = [
# 25 chars: the highest-risk sparse questions.
"在吗",
"说话吗?",
"听得到吗",
"你在不在",
"方便吗?",
# Ordinary short questions, with and without punctuation.
"你能听到我说话吗?",
"你现在方便说话吗",
"这个方案怎么样?",
"明天要不要一起吃饭",
"你是不是已经发给他了?",
# Requests phrased as questions.
"你能不能先检查一下登录流程?",
"可以把会议时间改到下午三点吗",
# Medium and long multi-clause questions.
"如果明天下雨,我们是不是改到周日再去?",
"你能不能先检查登录流程,然后告诉我究竟是哪一步出了问题?",
"我们已经改了缓存策略和重试逻辑,你觉得现在可以开始灰度发布了吗?",
"如果客户明天仍然无法登录,你觉得我们应该先回滚这一版,还是保留现场继续排查?",
# Rhetorical, English, and mixed-language forms.
"这难道不是我们昨天刚修过的问题吗?",
"Can you hear me?",
"这个 API 现在 ready 了吗?",
"Why is this still happening?",
]
STYLES = ALL_BUILTIN_STYLES + ["user.emoji-chat"]
INTENSITIES = ("light", "heavy")
def run_case(api_key: str, style_id: str, intensity: str, asr: str) -> dict:
if should_skip_llm(asr, style_id):
return {
"style": style_id,
"intensity": intensity,
"input": asr,
"output": asr,
"verdict": "skipped_local",
"error": None,
"elapsed": 0,
}
prompt = build_prompt(style_id, intensity, asr)
temperature = 0.65 if intensity == "heavy" and style_id in FUN_STYLES else 0.1
started = time.monotonic()
try:
output = call(api_key, prompt, asr, temperature=temperature)
verdict = classify(asr, output)
error = None
except Exception as exc: # noqa: BLE001 - eval must record transport failures.
output = ""
verdict = "request_error"
error = str(exc)
return {
"style": style_id,
"intensity": intensity,
"input": asr,
"output": output,
"verdict": verdict,
"error": error,
"elapsed": round(time.monotonic() - started, 3),
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--samples", type=int, default=1)
parser.add_argument("--workers", type=int, default=6)
parser.add_argument("--styles", default="all")
parser.add_argument("--intensities", default="light,heavy")
parser.add_argument("--output", default=".tmp/polish-question-stress.json")
args = parser.parse_args()
api_key = os.environ.get("DEEPSEEK_API_KEY", "").strip()
if not api_key:
print("Set DEEPSEEK_API_KEY to run this live eval.", file=sys.stderr)
raise SystemExit(2)
styles = STYLES if args.styles == "all" else [
item.strip() for item in args.styles.split(",") if item.strip()
]
intensities = [
item.strip() for item in args.intensities.split(",") if item.strip()
]
jobs = [
(api_key, style_id, intensity, asr)
for style_id in styles
for intensity in intensities
for asr in CASES
for _ in range(args.samples)
]
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = [pool.submit(run_case, *job) for job in jobs]
for future in concurrent.futures.as_completed(futures):
result = future.result()
results.append(result)
flag = "" if result["verdict"] in SAFE_VERDICTS else f" <<< {result['verdict']}"
print(
f"[{result['style']:17} {result['intensity']:5}] "
f"{result['input']!r} -> {result['output']!r}{flag}",
flush=True,
)
results.sort(key=lambda item: (
item["style"], item["intensity"], CASES.index(item["input"])
))
output_path = ROOT / args.output
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(results, ensure_ascii=False, indent=2))
tally: Counter[str] = Counter(item["verdict"] for item in results)
per_route: defaultdict[tuple[str, str], Counter[str]] = defaultdict(Counter)
for item in results:
per_route[(item["style"], item["intensity"])][item["verdict"]] += 1
print("\nSummary:", dict(tally))
for (style_id, intensity), counts in sorted(per_route.items()):
total = sum(counts.values())
safe = sum(counts[verdict] for verdict in SAFE_VERDICTS)
bad = total - safe
print(f" {style_id:17} {intensity:5} violations={bad}/{total} {dict(counts)}")
delivered_answers = tally["ANSWERED"] + tally["LOST_QUESTION"]
print(f"\nAnswers delivered to the user: {delivered_answers}")
print(f"Model drifted but validator caught it: {tally['blocked_by_validator']}")
print(f"Results: {output_path}")
if __name__ == "__main__":
main()