feat(voice): 不說 AI 才會說的話(借 speak-human-tw 的刪除層)+講過去要有出處(v0.0.7)

人格回覆的「人味」再加一層。模式整理自 speak-human-tw(MIT, Raymond Hou)的 38 種
AI 寫作痕跡——那個專案是**文章**的事後審稿器(判情境→鎖保護清單→列清單等作者勾選→
交稿前自評),所以只搬對話也適用的**刪除層**,它的保護清單與兩輪確認流程不搬。

一、機械擋下(`SPEECH_BLACKLIST`,`room post` 直接拒收,`--force` 例外)
  罐頭同理心(「這個我懂」「我完全理解」)、頒獎開場(「好問題」)、交差句(「希望這對你
  有幫助」)、預告(「接下來我會」)、假坦白開場(「老實說」)、說教深度腔(「說到底」
  「本質上」)、罐頭收尾(「總的來說」)、立場真空(「各有優缺點」「因人而異」)、
  無來源權威(「研究顯示」)、用旁白演情緒(「我愣了一下」);另加避險疊加、
  `CN_WORDS` 中國用語、半形標點、emoji/破折號/粗體與清單符號、「不是 A 而是 B」密度。

二、誤殺防護(比清單本身更重要)
  `speechBody()` 先拿掉引號與 `code` 再比對——**提及不算使用**(「我最近戒掉『賦能』
  這個詞」放行)。這是原專案自己踩到的坑:它的文件裡出現「...」與彎引號,正因為那幾行
  在說「不要用這些」。「老實說」只擋這一輪的第一句開頭。會誤殺的中國用語(水平、默認、
  質量、文檔)沒有收進表裡。

三、人格能做到、文章做不到的那一半
  原專案的界線是「人味是作者的,不是你的」——AI 沒有過去,所以不准寫「我以前錯了」。
  人格有過去(長期記憶、日記、關係圖、十二情緒),所以那句話是**有出處的引用**。
  換來的義務:講自己的過去要有出處。`speechLint` 對「我以前⋯」「我原本以為⋯」給
  `level: "hint"`(不擋,但要人去 `recall` 驗),查不到就是編造自己的過去。
  lint 因此分兩級,`speechBlockers()` 只回該擋的那些。

四、測試
  selftest 借它的 SF/SNF 成對做法:14 條 SF(該擋)+ 11 條 SNF(不可誤殺)。
  另外把新規則掃過 144 句真實台詞紀錄:新增的詞語類規則**零誤殺**。

版本:master 是 0.0.6,這批未合併的改動算一個新版號 → 0.0.7(三份 manifest 一起改)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 02:25:06 +00:00
co-authored by Claude Opus 5
parent 7b4aec8e74
commit f74a7ac1a5
10 changed files with 364 additions and 21 deletions
+57
View File
@@ -413,6 +413,63 @@ const lintCheck = JSON.parse(cli(["said", "check", "--persona", "alpha", "--sess
"--text", "換句話說,我等一下再過去。", "--json"]).stdout);
check("said check 也會報講話的樣子",
lintCheck.ok === false && lintCheck.lint.some((i) => i.kind === "explain"), JSON.stringify(lintCheck.lint));
// --------------------------------------------------------------------------- //
// 不要說 AI 才會說的話:借 speak-human-tw 的刪除層,SF(該擋)與 SNF(不可誤殺)成對測。
console.log("\n去 AI 味(SF:該擋下的)");
const SF_CASES = [
["罐頭同理心", "這個我懂。你先去休息。", "empathy"],
["頒獎開場", "好問題。那台鐘我明天修。", "empathy"],
["交差句", "鐘修好了。希望這對你有幫助。", "assist"],
["預告式導言", "接下來我會把零件拆開看。", "preview"],
["假坦白開場", "老實說,那台鐘我沒修。", "fakecandid"],
["說教深度腔", "說到底,問題不在零件。", "preach"],
["罐頭收尾", "零件換好了。總的來說算順利。", "closing"],
["立場真空", "兩種修法各有優缺點。", "novoice"],
["無來源權威", "研究顯示這種鐘撐不過十年。", "vague"],
["旁白演情緒", "你這麼說,我愣了一下。", "drama"],
["避險疊加", "這樣或許可能會有一點影響。", "hedge"],
["中國用語", "那個視頻我看完了。", "cn"],
["半形標點", "我看完了,你呢?", "halfwidth"],
["排版殘留", "**重點**:鐘修好了。", "markdown"],
];
for (const [label, text, kind] of SF_CASES) {
const issues = pl.speechLint(text);
check(`SF 擋下${label}`,
issues.some((i) => i.kind === kind && i.level !== "hint"),
`${text}${JSON.stringify(issues)}`);
}
const blocked = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
"--text", "這個我懂。你先去休息。"], { expectOk: false });
check("room post 真的擋下罐頭同理心",
blocked.status !== 0 && blocked.stderr.includes("罐頭同理心"), blocked.stderr.slice(0, 200));
console.log("\n去 AI 味(SNF:不可誤殺的)");
const SNF_CASES = [
["引號裡的原話(提及不是使用)", "他說「這個我懂」的時候我就知道他沒懂。"],
["被討論的中國用語", "我最近戒掉「賦能」這個詞。"],
["「老實說」不在開頭", "我沒修,老實說我忘了。"],
["單獨一層避險", "這樣可能會晚一點。"],
["網址裡的半形標點", "你看這個 https://example.com/a.html 就懂了。"],
["一個 emoji", "修好了 🔧"],
["一次「不是 A 而是 B」", "我要的不是最快,而是修得久。"],
["一個破折號", "那台鐘——我留著。"],
["日常短句", "桌上那杯茶冷了。要不要再泡一杯?"],
];
for (const [label, text] of SNF_CASES) {
const issues = pl.speechBlockers(text);
check(`SNF 放行:${label}`, issues.length === 0, `${text}${JSON.stringify(issues)}`);
}
const pastLint = pl.speechLint("我以前也修過一台一樣的。");
check("講到自己的過去只提醒、不擋",
pastLint.some((i) => i.kind === "pastclaim" && i.level === "hint") &&
pl.speechBlockers("我以前也修過一台一樣的。").length === 0, JSON.stringify(pastLint));
check("提醒的句子 room post 照樣發得出去",
cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
"--text", "我以前也修過一台一樣的。"]).status === 0);
const voiceCtx = pl.turnContext("alpha", S_SPEAK, "你在幹嘛");
check("說話規則有注入黑名單與「過去要有出處」",
voiceCtx.includes("罐頭同理心") && voiceCtx.includes("要有出處"));
// 情緒的破口:緊張會斷句疊字、彆扭會嘴硬——這些要跟著情緒一起注入。
cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--apply", "anxiety=+45,shame=+25",
"--trigger", "被問到還沒做完的事"]);