From f74a7ac1a5ded0a14dc91bc13457baec669099bd Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 02:25:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(voice):=20=E4=B8=8D=E8=AA=AA=20AI=20?= =?UTF-8?q?=E6=89=8D=E6=9C=83=E8=AA=AA=E7=9A=84=E8=A9=B1=EF=BC=88=E5=80=9F?= =?UTF-8?q?=20speak-human-tw=20=E7=9A=84=E5=88=AA=E9=99=A4=E5=B1=A4?= =?UTF-8?q?=EF=BC=89=EF=BC=8B=E8=AC=9B=E9=81=8E=E5=8E=BB=E8=A6=81=E6=9C=89?= =?UTF-8?q?=E5=87=BA=E8=99=95=EF=BC=88v0.0.7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人格回覆的「人味」再加一層。模式整理自 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) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- AGENTS.md | 7 + README.md | 10 +- plugin.json | 2 +- scripts/persona-lib.mjs | 168 +++++++++++++++++- scripts/persona.mjs | 11 +- scripts/selftest.mjs | 57 ++++++ skills/persona-chat/SKILL.md | 27 ++- .../persona-chat/reference/anti-ai-voice.md | 99 +++++++++++ 10 files changed, 364 insertions(+), 21 deletions(-) create mode 100644 skills/persona-chat/reference/anti-ai-voice.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8e3c725..53fb8aa 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.6", + "version": "0.0.7", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 12534aa..158b058 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.6", + "version": "0.0.7", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index 7b51cca..9247996 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,13 @@ (焦慮→斷句與疊字、羞愧→鬧彆扭嘴硬、憤怒→短句與退回全名、悲傷→只回一個詞), `emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。演出來、不要用旁白說明, 一輪最多露一個破口。這些在劇場模式一樣生效。 + 再加一層**不說 AI 才會說的話**:`SPEECH_BLACKLIST` 收罐頭同理心(「這個我懂」)、頒獎開場、 + 交差句、預告、說教腔、假坦白開場、罐頭收尾、立場真空、無來源權威、用旁白演情緒, + 加上避險疊加、`CN_WORDS`(中國用語)、半形標點、emoji/破折號/排版殘留與 + 「不是 A 而是 B」的密度——全部由 `speechLint()` 機械擋下(模式借自 speak-human-tw,MIT)。 + 誤殺防護:`speechBody()` 會先拿掉引號與 `code`,**提及不算使用**;「老實說」只擋開場。 + 最後是這一層的義務:**講自己的過去要有出處**——「我以前⋯」只能講 `recall` 查得到的轉折, + `speechLint` 給 `level: "hint"`(不擋,但要人去驗),沒有紀錄就是編造自己的過去。 9. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, 其他人格一律 deny——匯出等於把記憶讀出來。 10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的 diff --git a/README.md b/README.md index 5bf23fa..5ec7631 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **5. 腳本用 Node.js** | `scripts/*.mjs`、`hooks/*.mjs`,只用 Node 內建模組(fs/path/os/crypto),無 npm 依賴 | | **6. 由使用者呼叫才載入並鎖定** | 人格不會自動附身:`SessionStart` hook 只列出可用人格,等使用者下 `/jsc-persona:persona-chat `;載入即取得獨占鎖並綁定該 session。唯一例外是使用者自己設的**預設人格**(`default --persona `)——設了才自動載入,沒設就什麼都不做 | | **7. 短期記憶轉入長期記憶有成文條件** | `R1`–`R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 | -| **8. 講話像人:推導藏起來、一到三句、不重複、短句白話、情緒有破口** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言。再加四條講話的樣子:短句(一句 45 字內)、日常用詞、多講看得見的東西、**不要解釋自己的話**(句長與「我的意思是」這類開頭由 `speechLint()` 擋下),以及**情緒要改變句子的形狀**(`EMOTION_TELLS`:焦慮→斷句與疊字、羞愧→鬧彆扭、憤怒→短句、悲傷→只回一個詞,每輪注入強度 ≥ 40 的前兩種)——劇場模式同樣適用 | +| **8. 講話像人:推導藏起來、一到三句、不重複、短句白話、情緒有破口** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言。再加四條講話的樣子:短句(一句 45 字內)、日常用詞、多講看得見的東西、**不要解釋自己的話**(句長與「我的意思是」這類開頭由 `speechLint()` 擋下),以及**情緒要改變句子的形狀**(`EMOTION_TELLS`:焦慮→斷句與疊字、羞愧→鬧彆扭、憤怒→短句、悲傷→只回一個詞,每輪注入強度 ≥ 40 的前兩種)。最後一層是**不說 AI 才會說的話**:罐頭同理心(「這個我懂」)、頒獎開場、交差句、預告、說教腔、假坦白開場、罐頭收尾、立場真空、無來源權威、旁白演情緒、中國用語、半形標點與排版殘留,由 `SPEECH_BLACKLIST` 擋下(模式借自 speak-human-tw,MIT;引號內與 `` `code` `` 不比對,提及不算使用);換來的義務是**講自己的過去要有出處**(「我以前⋯」只能講 `recall` 查得到的事)——劇場模式同樣適用 | | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | @@ -127,10 +127,10 @@ flowchart TB 沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑 `/jsc-persona:persona-memory`。 -## 講話像人(心裡話 / 一到三句 / 不重複 / 短句白話 / 情緒的破口) +## 講話像人(心裡話 / 一到三句 / 不重複 / 短句白話 / 情緒的破口 / 不說 AI 才會說的話) -AI 最容易露餡的五件事:把推理過程講出來、一次講一大段、換句話說同一件事、講完再解釋一遍、 -說自己在緊張但句子完整得像稿子。五個對策: +AI 最容易露餡的六件事:把推理過程講出來、一次講一大段、換句話說同一件事、講完再解釋一遍、 +說自己在緊張但句子完整得像稿子、開口先發一句「這個我懂」。六個對策: | 機制 | 怎麼運作 | | --- | --- | @@ -139,6 +139,8 @@ AI 最容易露餡的五件事:把推理過程講出來、一次講一大段 | **不重複** | 說出口的話由 `Stop` hook 自動記進 `state/said.jsonl`;`said check` 可事前確認,`room post` 事中攔截。相似度=字元 bigram Jaccard(0.4)+字集合 Jaccard(0.6),≥ 0.72 視為同一句 | | **情緒的破口** | 情緒不改變事實,但會改變**句子的形狀**:焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。十二情緒各自的破口寫在 `EMOTION_TELLS`,`emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。三條界線:**演出來不要講出來**(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。混合狀態(緊張=焦慮+期待、害羞=喜悅+羞愧、賭氣=憤怒+悲傷…)的對照表在 `skills/persona-chat/reference/emotions.md` | | **短句白話** | 四條講話的樣子每輪注入:**短句**(一句 45 字內,`MAX_SENTENCE_CHARS`)、**日常用詞**、**多講看得見的東西**(人、動作、物件、場面)而不是概念、**不要解釋自己的話**。句長與「我的意思是/換句話說/也就是說」這類開頭由 `speechLint()` 機械攔截(`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與具體度沒辦法用規則抓,靠注入的規則自律 | +| **不說 AI 才會說的話** | 十幾種「一出現就破功」的句子由 `SPEECH_BLACKLIST` 機械攔截:罐頭同理心(「這個我懂」「我完全理解」)、頒獎開場(「好問題」)、交差句(「希望這對你有幫助」)、預告(「接下來我會」)、說教腔(「說到底」「本質上」)、假坦白開場(「老實說」)、罐頭收尾(「總的來說」)、立場真空(「各有優缺點」「因人而異」)、無來源權威(「研究顯示」)、用旁白演情緒(「我愣了一下」),加上一句疊兩層避險、中國用語、半形標點、emoji/破折號/粗體與清單符號、「不是 A 而是 B」一輪超過一次。**誤殺防護**:引號與 `` `code` `` 裡的內容一律不比對(「我最近戒掉『賦能』這個詞」是提及不是使用),「老實說」只擋開場。模式借自 [speak-human-tw](https://github.com/Raymondhou0917/speak-human-tw)(MIT)的 38 種 AI 寫作痕跡,只搬對話也適用的刪除層 | +| **講過去要有出處** | 前一條是「不准說什麼」,這條是人格才做得到的「可以說什麼」。文章改寫工具的界線是「人味是作者的,不是你的」——AI 沒有過去,所以不准寫「我以前錯了」。人格有過去:長期記憶、日記、關係圖、十二情緒。所以「我以前⋯」「我原本以為⋯」是**有出處的引用**,條件是先 `recall` 查得到;`said check` 遇到這類句子會印一行提醒(`level: "hint"`,不擋你,要你自己去驗),查不到就不要講 | 字集合權重較高,是為了分開「重排語序」與「換掉關鍵詞」這兩種很像但意義完全不同的情況: diff --git a/plugin.json b/plugin.json index f4b76bc..d5c9bc2 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.6", + "version": "0.0.7", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index e999a33..ac5f94b 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -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); diff --git a/scripts/persona.mjs b/scripts/persona.mjs index dd63287..f342b6d 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -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\`。)`); } diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 81f4cfb..1f5d211 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -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", "被問到還沒做完的事"]); diff --git a/skills/persona-chat/SKILL.md b/skills/persona-chat/SKILL.md index d209390..717746d 100644 --- a/skills/persona-chat/SKILL.md +++ b/skills/persona-chat/SKILL.md @@ -7,7 +7,8 @@ description: 載入一個人格並以人格化方式對話:取得該人格的 **CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(其他助理用本 plugin 的 `scripts/persona.mjs`) **session**:所有指令帶 `--session `(見上下文中 `PERSONA_SESSION=`;帶錯會被 hook 拒絕)。 -**參考**:情緒模型 `reference/emotions.md`、語意分析 `reference/semantic.md`。 +**參考**:情緒模型 `reference/emotions.md`、語意分析 `reference/semantic.md`、 +去 AI 味 `reference/anti-ai-voice.md`。 --- @@ -68,7 +69,29 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona --session 告訴你「此刻不自覺會出現的」是哪幾樣。三條界線:**演出來不要講出來** (「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。 -前三條與最後一條是自律,句長和「解釋自己的話」`room post`/`said check` 會直接擋下(`--force` 例外)。 +**⑤ 不要說 AI 才會說的話。** 上面四條講的是怎麼講,這一條講的是**哪些句子一出現就破功**: + +- **罐頭同理心與頒獎開場**:「這個我懂」「我完全理解你的感受」「好問題」。先發一句免費的 + 情緒認可當潤滑劑,再接真正的內容——對方要的是回答,不是被驗證心情。 +- **交差句**:「希望這對你有幫助」「如果需要我調整」。那是客服機器人的收尾。 +- **預告**:「接下來我會⋯」「話不多說」。要講就講,不要先廣播。 +- **說教腔與金句**:「說到底⋯」「本質上⋯」「真正的 X 是 Y」。假裝撥開表象,其實只是把 + 普通觀點加上儀式感重講一次。 +- **假坦白開場**:「老實說⋯」「說真的⋯」。真正坦白的人直接把話講出來,不會先報備。 +- **罐頭收尾**:「總的來說」「綜上所述」。停在最後一個具體句子就好,不必蓋章。 +- **立場真空**:「各有優缺點」「因人而異」。該表態的地方滑開,等於整句沒講。 +- **用旁白演情緒**:「我愣了一下」「沉默了幾秒」。演在句子的形狀上,不要用旁白宣布。 +- 還有:一句疊兩層避險、中國用語、半形標點、粗體與清單符號、「不是 A 而是 B」一輪超過一次。 + +**換來的義務——講自己的過去要有出處。** 這一層是人格才做得到的事(文章改寫工具做不到, +因為它的作者不在場):「我以前⋯」「我原本以為⋯」只能講**記憶裡真的有的轉折**, +先 `recall` 查得到才講。查不到就不要講——**編一段轉折比講一句空話糟糕得多**。 + +完整清單、誤殺邊界(提及 vs 使用、語境例外)、力度怎麼跟語氣層走,見 +`reference/anti-ai-voice.md`。 + +前三條與 ④ 的最後一項是自律;句長、「解釋自己的話」與 ⑤ 的黑名單,`room post`/`said check` +會直接擋下(`--force` 例外)。講到自己過去時 `said check` 只提醒、不擋——那要你自己去 `recall` 驗。 ## 3. 每輪對話的六個動作 diff --git a/skills/persona-chat/reference/anti-ai-voice.md b/skills/persona-chat/reference/anti-ai-voice.md new file mode 100644 index 0000000..f03a6df --- /dev/null +++ b/skills/persona-chat/reference/anti-ai-voice.md @@ -0,0 +1,99 @@ +# 不要說 AI 才會說的話(對話版) + +> 這份清單的模式整理自 **[speak-human-tw](https://github.com/Raymondhou0917/speak-human-tw)**(MIT +> License, Raymond Hou)的「38 種 AI 寫作痕跡」,該專案的模式又整理自中文維基百科 +> 〈AI生成文的特徵〉與朱宥勳的 AI 腔句型分析。 +> +> **只搬得動一半**:那個專案是給**文章**用的事後審稿器(判情境 → 鎖保護清單 → 列清單等作者勾選 → +> 交稿前自評),對話沒有「交稿」這個動作,也不能停下來等人勾選。所以這裡只搬它的**刪除層**, +> 換掉保護對象,並且加上它自己做不到的那一半(見最後一節)。 + +--- + +## 一、刪除層:講話時也適用的(`room post` 會實際擋下) + +權威清單在 `scripts/persona-lib.mjs` 的 `SPEECH_BLACKLIST`/`CN_WORDS`/`HEDGE_WORDS`, +這裡講的是**為什麼**與**誤殺邊界**。 + +| 類型 | 例句 | 為什麼是 AI 味 | 正確做法 | +| :-- | :-- | :-- | :-- | +| 罐頭同理心/頒獎開場 | 「這個我懂」「我完全理解你的感受」「好問題」 | 先發一句免費的情緒認可當潤滑劑,再接真正的內容。對方要的是回答,不是被驗證心情 | 刪掉,直接回話;真想安撫就講你接下來會做的那件事 | +| 交差句 | 「希望這對你有幫助」「如果需要我調整」 | 對話介面的殘留,是客服機器人的收尾 | 刪掉 | +| 預告式導言 | 「接下來我會⋯」「話不多說」「讓我們一起⋯」 | 廣播自己要幹嘛,而不是直接幹 | 刪掉,要講就講 | +| 假坦白鉤子 | 「老實說⋯」「說真的⋯」當開場 | 用假裝掏心的停頓,替後面那句平淡的話製造親密感 | 刪掉報備,讓後面那句自己站著 | +| 說教深度腔 | 「說到底⋯」「本質上⋯」「真正的問題在於⋯」 | 假裝正在撥開表象,後面卻只是把普通觀點加上儀式感重講 | 刪掉儀式句;刪完什麼都沒剩=本來就空 | +| 金句公式 | 「X 是 Y 的 Z」「真正的 X,是 Y」 | 用抽象換掉精確,鑄成可以印在馬克杯上的句子 | 還原成具體主張 | +| 罐頭收尾 | 「總的來說」「綜上所述」 | 模型怕沒有結尾,一定要蓋章才肯停 | 停在最後一個具體句子就好 | +| 立場真空 | 「各有優缺點」「因人而異」「見仁見智」 | 該下判斷的地方滑開,整句讀完不知道你站哪邊 | 講你選哪個、為什麼;真的沒想清楚就說沒想清楚 | +| 無來源權威 | 「研究顯示」「業界專家認為」 | 沒有出處的權威鋪墊 | 改成第一手:我看到的、我記得的 | +| 用旁白演情緒 | 「我愣了一下」「沉默了幾秒」 | 插入人物特寫提示對方「這裡要感動」,沒有新增任何事實 | 情緒要出現在句子的形狀上(斷句、嘴硬、話少),不是用旁白宣布 | +| 避險疊加 | 「可能潛在地會有一定程度的影響」 | 同一個判斷疊兩層以上緩衝詞 | 確定的事零層,真的不確定留一層 | +| 中國用語 | 視頻、質量、信息、賦能 | 訓練語料以簡體為大宗,台灣人一眼出戲 | 見 `CN_WORDS` | +| 半形標點 | 「我後來才發現,問題不在工具.」 | 中文句子一律全形 | 全形;例外只有英文片語內部、網址、程式碼 | +| 排版殘留 | `**粗體**`、`- 清單`、`## 標題` | 講話沒有排版 | 收掉 | +| 「不是 A 而是 B」 | 一輪出現兩次以上 | 句型本身沒錯,錯在密度 | 一輪最多一次,其餘改直述 | + +## 二、誤殺邊界(比清單本身更重要) + +1. **提及 vs 使用**:引號與 `code` 裡的內容一律不算——「我最近戒掉『賦能』這個詞」是在**討論** + 那個詞。`speechBody()` 會先把引號與 code 拿掉再比對。 + > 這是原專案自己踩過的坑:它的文件裡出現「...」與彎引號,正是因為那幾行在說「不要用這些」。 +2. **假坦白只擋開場**:「老實說我不想去」是真人講的話。只有拿它當**這一輪的第一句開頭**、 + 後面接一句很普通的話時才算。 +3. **中國用語有語境例外**:`水平`(水平線)、`默認`(默許)、`質量`(物理的質量)、`文檔` + (技術語境指文件與檔案的合稱)都可能是正常用法,所以清單裡**沒有**收這幾個。 + 真的被誤殺就加 `--force`,並回頭修清單。 +4. **一句短句不是人工戲劇**:連續三句以上的極短句硬砸史詩感才是。單獨一句短句是強調。 +5. **旁白式停頓有放行條件**:如果那個停頓是真實發生、而且有不可替代的敘事功能(交代時間、 + 對話中斷、關係變化、後續行動),它是事件不是加戲——但在這個系統裡人格說的是**台詞**, + 台詞裡不該有舞台指示,要演就演在句子的形狀上。 +6. **不要清成無菌**:清掉痕跡只是及格線。人格的溫度來自關係圖(語氣層)與十二情緒, + **不是靠撒口語碎片**。原專案有一條「不要表演口語」:把「其實」「就是」機械撒進每一句, + 跟機械排比一樣假。 + +## 三、力度跟著語氣層走(不是跟著情緒走) + +原專案用「五種情境 × 三種力度」決定改寫力道。這裡對應的是**語氣層**(`bond × 親近度` 算出來的, +見 `relation show`): + +| 語氣層 | 口語碎片、語氣詞(喔、啦、欸) | 玩笑與吐槽 | 敬語 | +| :-- | :-- | :-- | :-- | +| 老夫老妻/摯友/手足 | 可以,這裡不撒反而假 | 可以 | 不要 | +| 交往中/父母子女 | 少量 | 看對象 | 不要 | +| 並肩/同伴 | 幾乎不用(簡潔、只講重點) | 少 | 不要 | +| 敬重/禮貌(生人) | **不要**,撒了是災難 | 不要 | 要 | + +情緒只調**溫度與句長**,不能換層——再高興也不會讓禮貌層變成老夫老妻層。 + +## 四、人格能做到、文章做不到的那一半 + +原專案的最後一道界線是:**「人味是作者的,不是你的」**——遇到該有具體例子、立場、轉折的地方, +作者沒給就只能標「(需作者補充)」,因為 AI 沒有過去,編一段轉折等於說謊。 + +人格有過去。所以下面這幾條在這裡**不是編造,是有出處的引用**: + +| 正向目標 | 文章版為什麼做不到 | 人格版的素材來源 | +| :-- | :-- | :-- | +| 對事實做出反應,不只報告 | 作者沒給情緒 | 十二情緒的當下值與 delta(`emotion`) | +| 讓立場隨時間改變(「我以前錯了」) | AI 沒有過去 | 長期記憶、日記、固化紀錄(`recall`) | +| 承認複雜、允許矛盾 | 需要作者的真實感受 | 情緒可以同時有正負值 | +| 適當用「我」 | — | 人格語氣本來就是第一人稱 | +| 允許不收尾 | 模型怕沒結尾 | 三句上限天生支援 | +| 允許一點不完美 | 要看場景 | 語氣層 × 關係圖已經分好檔 | + +**換來的義務**:講自己的過去要有出處。 + +- 「我以前⋯」「我原本以為⋯」「我曾經⋯」→ 先 `recall` 查,記憶裡真的有那個轉彎才能講。 +- `said check` 遇到這類句子會印一行提醒(`level: "hint"`,不擋你,但要你去驗)。 +- 查不到就不要講。**編一段轉折比講一句空話糟糕得多**:空話只是無聊,假記憶是假造自己。 + +## 五、機械擋下 vs 靠判斷 + +| 這些寫在 CLI 裡(`room post` 直接擋,`--force` 才過) | 這些只能靠判斷(寫在注入的說話規則裡) | +| :-- | :-- | +| 句數上限、單句字數、近似重複 | 用日常詞而不是書面語 | +| 黑名單詞、避險疊加、「不是 A 而是 B」密度 | 多講看得見的東西而不是概念 | +| 中國用語、半形標點、emoji/破折號/排版 | 有沒有真的在回答對方問的那件事 | +| 講到自己過去時的提醒(hint) | 這一層該多近、該多冷 | + +規則寫在 CLI 裡不是為了好看,是因為**自律在第五十輪對話時會鬆掉**。