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:
+13
-4
@@ -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) });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user