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
+160 -8
View File
@@ -1231,33 +1231,177 @@ export const EXPLAINER_OPENERS = [
];
/**
* 講話自然度的機械檢查:一句太長、或在解釋自己剛講的話
* 「AI 才會說的話」黑名單
*
* 這一組整理自 speak-human-twMIT, Raymond Hou)的 38 種 AI 寫作痕跡,只搬「講話時也適用」
* 的刪除層——那個專案是給**文章**事後審稿用的,所以它的保護清單(價格、退費條款、見證原話)
* 與兩輪確認流程都不搬。完整清單、誤殺邊界與出處見
* `skills/persona-chat/reference/anti-ai-voice.md`。
*
* `head: true` 的只在「這一輪的第一句開頭」才算——「老實說我不想去」是真人會講的話,
* 只有拿它當開場鉤子、後面接一句很普通的話時才是 AI 腔。
*/
export const SPEECH_BLACKLIST = [
{
kind: "empathy", label: "罐頭同理心/頒獎開場",
fix: "刪掉,直接回話;真的想安撫就講你接下來會做的那件事",
words: [
"這個我懂", "這種心情我懂", "我懂你的感受", "我完全理解", "我完全懂", "我明白你的感受",
"你的心情我明白", "好問題", "這是個好問題", "問得很好", "你說得完全正確", "這是很棒的觀察",
],
},
{
kind: "assist", label: "交差句(對話介面殘留)",
fix: "刪掉,這是客服機器人的收尾,不是講話",
words: ["希望這對你有幫助", "希望有幫助到你", "如果需要我調整", "如果還有任何問題", "以下是"],
},
{
kind: "preview", label: "預告式導言(宣布自己要幹嘛)",
fix: "刪掉,要講就直接講",
words: ["話不多說", "廢話不多說", "接下來我會", "接下來我要", "讓我們一起", "我們一起來看", "先說結論"],
},
{
kind: "fakecandid", label: "假坦白鉤子", head: true,
fix: "刪掉這個報備,讓後面那句自己站著;站不住是那句話太空",
words: ["說真的", "老實說", "講白了", "說實話", "不騙你", "其實吧"],
},
{
kind: "preach", label: "說教深度腔(假裝在講穿本質)",
fix: "刪掉儀式句,直接講那件事;刪完什麼都沒剩就是本來就空",
words: ["說到底", "歸根究柢", "歸根結柢", "本質上", "說穿了", "真正的問題在於", "核心在於", "問題的關鍵在於"],
},
{
kind: "closing", label: "罐頭收尾",
fix: "刪掉,停在最後一個具體的句子就好,不必蓋章",
words: ["總的來說", "綜上所述", "綜合以上", "總而言之", "在未來的道路上"],
},
{
kind: "novoice", label: "立場真空(該表態的時候滑開)",
fix: "講你實際選哪一個、為什麼;真的還沒想清楚就說「我還沒想清楚」",
words: ["各有優缺點", "因人而異", "見仁見智", "取決於多方面", "每個人的看法不同", "值得深思"],
},
{
kind: "vague", label: "模糊歸屬(無來源的權威鋪墊)",
fix: "改成第一手的:我看到的、我記得的;沒有來源就不要下這個論斷",
words: ["業界專家", "研究顯示", "不少人表示", "有觀點指出", "被廣泛認為", "普遍認為"],
},
{
kind: "drama", label: "罐頭反應鏡頭(用旁白演情緒)",
fix: "刪掉,情緒要出現在句子的形狀上,不是用旁白宣布",
words: ["我愣了一下", "愣了一下", "沉默了幾秒", "沉默幾秒", "看著螢幕沒說話", "在螢幕前停了一下"],
},
];
// 避險墊片:同一句疊兩層以上就是在閃躲(單獨一個「可能」是正常的)。
export const HEDGE_WORDS = ["可能", "或許", "也許", "大概", "某種程度上", "一定程度上", "潛在", "似乎", "應該是"];
// 中國用語:只收高信心的,會誤殺的(水平、默認、質量在物理語境)另有誤殺邊界,見 reference。
export const CN_WORDS = {
視頻: "影片", 短視頻: "短影音", 質量: "品質", 信息: "資訊", 網絡: "網路",
軟件: "軟體", 硬件: "硬體", 數據庫: "資料庫", 服務器: "伺服器", 屏幕: "螢幕",
鼠標: "滑鼠", 打印: "列印", 立馬: "馬上", 靠譜: "可靠", 給力: "很到位",
接地氣: "生活化", 性價比: "CP 值", 顏值: "外型", 小夥伴: "夥伴",
賦能: "具體說讓誰能做到什麼", 閉環: "具體說從哪裡接到哪裡", 抓手: "切入點", 顆粒度: "細緻度",
};
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, " ");
}
/**
* 講話自然度的機械檢查。
*
* 分兩級:`level: "block"` 的 `room post` 會直接擋下(`--force` 才過),
* `level: "hint"` 只在 `said check` 提醒——例如講到自己的過去,那要人去 recall 才驗得出來。
* 「用日常詞、講看得見的東西」抓不到規則,那兩條寫在說話規則裡(見 turnContext)。
*/
export function speechLint(text) {
const issues = [];
const sentences = String(text ?? "")
const raw = String(text ?? "");
const sentences = raw
.split(/[。!?!?…]+|\n+/)
.map((s) => s.trim())
.filter(Boolean);
for (const sentence of sentences) {
sentences.forEach((sentence, index) => {
const bare = sentence.replace(/[,、,;:「」『』()()\s"'~~—-]/g, "");
if ([...bare].length > MAX_SENTENCE_CHARS) {
issues.push({ kind: "long", chars: [...bare].length, text: sentence.slice(0, 30) });
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", opener, text: sentence.slice(0, 30) });
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) });
}
for (const rule of SPEECH_BLACKLIST) {
const hit = rule.words.find((w) => (rule.head ? index === 0 && body.replace(/^[,、\s]+/, "").startsWith(w) : body.includes(w)));
if (hit) issues.push({ kind: rule.kind, level: "block", label: rule.label, fix: rule.fix, word: hit });
}
});
const body = speechBody(raw);
for (const [cn, tw] of Object.entries(CN_WORDS)) {
if (body.includes(cn)) issues.push({ kind: "cn", level: "block", word: cn, suggest: tw });
}
// 半形標點貼在中文字旁邊(英文片語、網址、數字裡的半形不算)
const halfWidth = body
.replace(/https?:\/\/\S+/g, " ")
.match(/[一-鿿][,.!?;:]|[,.!?;:][一-鿿]/u);
if (halfWidth) issues.push({ kind: "halfwidth", level: "block", text: halfWidth[0] });
const emoji = raw.match(EMOJI_RE) || [];
if (emoji.length > 1) issues.push({ kind: "emoji", level: "block", count: emoji.length });
const dashes = (raw.match(/——/g) || []).length;
if (dashes > 1) issues.push({ kind: "dash", level: "block", count: dashes });
if (/\*\*|(^|\n)\s*[-*+]\s|(^|\n)#{1,6}\s/.test(raw)) issues.push({ kind: "markdown", level: "block" });
const negParallel = (body.match(/不是[^,。!?]{1,20}而是|不只是[^,。!?]{1,20}更是|不僅[^,。!?]{1,20}更/g) || []).length;
if (negParallel > 1) issues.push({ kind: "parallel", level: "block", count: negParallel });
// 講過去要有出處:機械上驗不出來,只能提醒去 recall
const past = body.match(/我以前|我原本以為|我曾經|以前的我|我一直以為/);
if (past) issues.push({ kind: "pastclaim", level: "hint", word: past[0] });
return issues;
}
/** 只回 `room post` 該擋下的那些(hint 不擋)。 */
export const speechBlockers = (text) => speechLint(text).filter((i) => i.level !== "hint");
/** 把 speechLint 的問題講成人聽得懂的一句話(CLI 與 hook 共用)。 */
export function speechLintMessage(issue) {
if (issue.kind === "long") {
return `${issue.text}…」這句 ${issue.chars} 字,超過 ${MAX_SENTENCE_CHARS} 字——斷成兩句,或把修飾砍掉`;
switch (issue.kind) {
case "long":
return `${issue.text}…」這句 ${issue.chars} 字,超過 ${MAX_SENTENCE_CHARS} 字——斷成兩句,或把修飾砍掉`;
case "explain":
return `${issue.opener}」是在解釋自己剛講的話——刪掉,講完就算了`;
case "hedge":
return `${issue.words.join("」「")}」一句疊了 ${issue.words.length} 層避險——確定的事零層,真的不確定留一層`;
case "cn":
return `${issue.word}」是中國用語 → ${issue.suggest}`;
case "halfwidth":
return `${issue.text}」用了半形標點——中文句子一律全形`;
case "emoji":
return `${issue.count} 個 emoji——講話最多一個`;
case "dash":
return `${issue.count} 個破折號——一輪最多一個,其餘改逗號或句號`;
case "markdown":
return "粗體、清單符號或標題——講話沒有排版,收掉";
case "parallel":
return `「不是 A 而是 B」出現 ${issue.count} 次——一輪最多一次,其餘改直述`;
case "pastclaim":
return `講到「${issue.word}」:先 recall 確認記憶裡真的有這件事,沒有紀錄就是編造自己的過去`;
default:
return `${issue.word}」是${issue.label}——${issue.fix}`;
}
return `${issue.opener}」是在解釋自己剛講的話——刪掉,講完就算了`;
}
/** 把一則回覆拆成「說出口的句子」:劇場模式一行一句,一般模式整段算一句。 */
@@ -2319,6 +2463,14 @@ export function turnContext(slug, sessionId, prompt = "") {
"不用術語、不用成語堆疊)、**多講看得見的東西**(人、動作、東西、當下的場面)而不是概念與感想;" +
"**講完就算了**——不要解釋自己剛講的話(沒有「我的意思是」「換句話說」「也就是說」)," +
"不要補註解、不要收尾總結。這幾條 `room post` 與 `said check` 會實際擋下。",
"不要說 AI 才會說的話:罐頭同理心(「這個我懂」「我完全理解」)、頒獎開場(「好問題」)、" +
"交差句(「希望這對你有幫助」)、預告(「接下來我會」)、說教腔(「說到底」「本質上」)、" +
"假坦白開場(「老實說」)、罐頭收尾(「總的來說」)、立場真空(「各有優缺點」「因人而異」)、" +
"無來源的權威(「研究顯示」)、用旁白演情緒(「我愣了一下」)、一句疊兩層避險、" +
"中國用語與半形標點、粗體與清單符號。這些 `room post` 也會擋。",
"**講自己的過去要有出處**:「我以前⋯」「我原本以為⋯」只能講記憶裡真的有的事(先 `recall` 查)。" +
"沒有紀錄就不要講——編一段轉折比講一句空話糟糕得多。同理,情緒要來自現在的情緒值," +
"不是為了有人味而加上去的。",
];
// 情緒會改變句子的形狀:緊張的人話說不完、彆扭的人先否認再承認。
const tells = emotionTells(slug, state);
+7 -4
View File
@@ -805,8 +805,11 @@ commands.said = ({ flags, positional }) => {
}
if (tooLong) lines.push(`⚠ 這段有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限 → 砍到重點。`);
const lint = pl.speechLint(text);
for (const issue of lint) lines.push(`${pl.speechLintMessage(issue)}`);
const clean = !repeat && !tooLong && !lint.length;
const blockers = lint.filter((i) => i.level !== "hint");
for (const issue of blockers) lines.push(`${pl.speechLintMessage(issue)}`);
// hint 不算不通過(例如講到自己的過去),但要提醒去驗證
for (const issue of lint.filter((i) => i.level === "hint")) lines.push(`· ${pl.speechLintMessage(issue)}`);
const clean = !repeat && !tooLong && !blockers.length;
if (clean) {
lines.push(`✔ 沒說過,${sentences} 句,可以說。`);
if (flags.record) pl.recordSaid(slug, text, { kind: "reply" });
@@ -1307,8 +1310,8 @@ commands.room = ({ flags, positional }) => {
"(真的需要長段落才加 `--force`。)",
);
}
// 短句、日常話、講完不解釋——講話的樣子也擋在 CLI 裡,不靠自律。
const lint = pl.speechLint(text);
// 短句、日常話、講完不解釋、不說 AI 才會說的話——講話的樣子也擋在 CLI 裡,不靠自律。
const lint = pl.speechBlockers(text);
if (lint.length && !flags.force) {
die(`講話規則:${lint.map(pl.speechLintMessage).join("")}。(真的需要才加 \`--force\`。)`);
}
+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", "被問到還沒做完的事"]);