fix(speech): 修掉三個誤殺——書名號、變體選擇子 emoji、引述算進句長

三個都是「該放行卻被擋」,而且都只有幾行:

  * speechBody() 的「提及 ≠ 使用」豁免清單漏了《》:
    「我在看《說到底》這本書」被判 preach,「他推薦我看《質量與信息》」被判中國用語。
  * EMOJI_RE 把 \u{FE0F} 寫在字元類裡,`❤️`(U+2764 + VS16)被算成兩個 emoji 而誤擋;
    ZWJ 家族(👨‍👩‍👦)算三個、膚色(👍🏽)算兩個。改成「底字 + 變體/膚色 + ZWJ 續接」算一個。
  * 句長檢查用的是 sentence 而不是 body,引號內容與 `code` 都算進字數——
    引述使用者原話必被擋,等於不准引述。

「質量」「信息」這類語境相依的中國用語誤判沒有動(那要語意判斷,成本不對)。

selftest +11:SNF 放行 7 條(書名號兩種、三種 emoji 組合、引述長原話與長 code),
SF 仍擋 4 條(書名號之外真的在說教、引號之外自己講的長句、兩個 emoji、
一個 emoji 加一個帶變體選擇子的)——豁免只針對提及與引述,不是整句放行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 09:29:53 +00:00
co-authored by Claude Opus 5
parent 94a86c9703
commit 197ad3416f
2 changed files with 28 additions and 4 deletions
+15
View File
@@ -513,6 +513,11 @@ const SF_CASES = [
["中國用語", "那個視頻我看完了。", "cn"],
["半形標點", "我看完了,你呢?", "halfwidth"],
["排版殘留", "**重點**:鐘修好了。", "markdown"],
// 放寬《》與句長之後,這幾條仍然要擋——豁免只針對「提及/引述」,不是整句放行
["書名號之外真的在說教", "我在看《時間簡史》。說到底,問題不在零件。", "preach"],
["自己講的長句(引號之外)", `${"想跟你說一件從去年就一直放在心上沒講出口的事情".repeat(2)}`, "long"],
["兩個 emoji", "修好了 🔧 開心 🎉", "emoji"],
["一個 emoji 加一個帶變體選擇子的", "修好了 🔧 給你 ❤️", "emoji"],
];
for (const [label, text, kind] of SF_CASES) {
const issues = pl.speechLint(text);
@@ -536,6 +541,16 @@ const SNF_CASES = [
["一次「不是 A 而是 B」", "我要的不是最快,而是修得久。"],
["一個破折號", "那台鐘——我留著。"],
["日常短句", "桌上那杯茶冷了。要不要再泡一杯?"],
// 書名號也是「提及 ≠ 使用」:以前豁免清單漏了《》,這句被判 preach
["書名裡剛好有黑名單的詞", "我在看《說到底》這本書。"],
["書名裡的中國用語", "他推薦我看《質量與信息》。"],
// ❤️ 是 U+2764 + VS16;以前 EMOJI_RE 含 \u{FE0F},這一個字被算成兩個而誤擋
["帶變體選擇子的單一 emoji", "這個給你 ❤️"],
["ZWJ 組成的單一 emoji", "路上遇到他們 👨‍👩‍👦"],
["帶膚色的單一 emoji", "收到 👍🏽"],
// 句長以前算的是整句(含引號內容),引述使用者原話必被擋
["引述使用者的長原話", `他傳來「${"我今天真的很累而且不知道要從哪裡開始講起".repeat(2)}」,我看了兩次。`],
["引述長 code", `跑的是 \`${"x".repeat(80)}\` 這一行。`],
];
for (const [label, text] of SNF_CASES) {
const issues = pl.speechBlockers(text);