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:
+160
-8
@@ -1231,33 +1231,177 @@ export const EXPLAINER_OPENERS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* 講話自然度的機械檢查:一句太長、或在解釋自己剛講的話。
|
||||
* 「AI 才會說的話」黑名單。
|
||||
*
|
||||
* 這一組整理自 speak-human-tw(MIT, 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
@@ -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\`。)`);
|
||||
}
|
||||
|
||||
@@ -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", "被問到還沒做完的事"]);
|
||||
|
||||
Reference in New Issue
Block a user