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
+13 -4
View File
@@ -1985,7 +1985,12 @@ export const CN_WORDS = {
賦能: "具體說讓誰能做到什麼", 閉環: "具體說從哪裡接到哪裡", 抓手: "切入點", 顆粒度: "細緻度",
};
const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/gu;
// 一個 emoji = 底字 + 變體選擇子/膚色 + ZWJ 接上去的後續。
// 以前 `\u{FE0F}` 直接寫在字元類裡,`❤️`U+2764 + VS16)會被算成兩個而誤擋;
// 👨‍👩‍👦 這種 ZWJ 家族同理。
const EMOJI_BASE = "[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]";
const EMOJI_TAIL = "(?:[\\u{1F3FB}-\\u{1F3FF}]|\\u{FE0F}|\\u{20E3})*";
const EMOJI_RE = new RegExp(`${EMOJI_BASE}${EMOJI_TAIL}(?:\\u{200D}${EMOJI_BASE}${EMOJI_TAIL})*`, "gu");
/**
* 只留「真的說出口、而且是在使用而不是在討論」的部分。
@@ -1993,11 +1998,13 @@ const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/gu;
* 引號裡的原話與 `code` 一律拿掉再比對——「我最近戒掉『賦能』這個詞」是在**提及**那個詞,
* 不是在用它。這一條是 speak-human-tw 自己踩到的坑:它的文件裡出現「...」與彎引號,
* 正是因為那幾行在說「不要用這些」。
*
* 書名號《》也算:「我在看《說到底》這本書」講的是書名,不是在說教。
*/
function speechBody(text) {
return String(text ?? "")
.replace(/`[^`]*`/g, " ")
.replace(/「[^」]*」|『[^』]*』|"[^"]*"/g, " ");
.replace(/「[^」]*」|『[^』]*』|《[^》]*》|"[^"]*"/g, " ");
}
/**
@@ -2015,14 +2022,16 @@ export function speechLint(text) {
.map((s) => s.trim())
.filter(Boolean);
sentences.forEach((sentence, index) => {
const bare = sentence.replace(/[,、,;:「」『』()()\s"'~~—-]/g, "");
const body = speechBody(sentence);
// 句長算的是「自己講的字」:引述使用者原話與 `code` 不該算進去,
// 不然「他跟我說『……(很長一段)』」必被擋,等於不准引述。
const bare = body.replace(/[,、,;:「」『』《》()()\s"'~~—-]/g, "");
if ([...bare].length > MAX_SENTENCE_CHARS) {
issues.push({ kind: "long", level: "block", chars: [...bare].length, text: sentence.slice(0, 30) });
}
const head = sentence.replace(/^[「『((]+/, "");
const opener = EXPLAINER_OPENERS.find((w) => head.startsWith(w));
if (opener) issues.push({ kind: "explain", level: "block", opener, text: sentence.slice(0, 30) });
const body = speechBody(sentence);
const hedges = HEDGE_WORDS.filter((w) => body.includes(w));
if (hedges.length >= 2) {
issues.push({ kind: "hedge", level: "block", words: hedges, text: sentence.slice(0, 30) });
+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);