feat(emotion): 情緒先行、走向、飽和與偏差稽核(四層,v0.0.7 同批)

參考兩篇 ithelp 文章(Day25 情緒辨識模組、Day26 整合與情緒記憶池)的**架構**,
實作全部重寫——它們的六類關鍵字 first-match、罐頭哲學回應與模式切換都比現有的
十二情緒模型差,照抄是降級,而且它的罐頭回應正是上一個 commit 剛封殺的罐頭同理心。

一、輸入端:`readUserEmotion(text)`
  在這之前情緒**全部是人格自己填的** `--apply`,而人不會主動給自己扣分——正向一路貼頂、
  負向整天不動。現在每輪先讀對方那句話:十二類加權詞表(強3/一般2/弱1)、程度副詞乘係數、
  **否定會擋掉那次命中**(「我不害怕」不算 fear)、引號與 `code` 內不比對(提及不算使用)、
  標點只放大既有訊號不憑空長出新情緒(「居然修好了!!」不會被讀成憤怒)。
  強度用飽和曲線不是線性乘。回的是**訊號不是判定**,人格可以推翻。`emotion --read` 可單測。

二、怎麼接:`RESPONSE_STANCE`
  十二種各一條**動作**(悲傷→先接住不要急著給解法;憤怒→不辯解,先認可能認的那一小塊),
  不是罐頭句——罐頭句由 `SPEECH_BLACKLIST` 擋著。

三、走向:`state/felt.jsonl` + `feltTrend()`
  每輪的偵測結果與套用的 delta 都記下來,近重遠輕的加權算最近 5 輪的趨勢
  (不用多數決:會被離群值主導又丟掉強度),注入「他最近 3 輪的走向:悲傷 ↘ 在退」。
  新檔案已歸到 Gitea 檔案區(高頻)。

四、情緒調節:飽和與單輪預算
  `applyEmotion` 不再是加完直接 clamp。飽和=越接近端點同方向漲越慢(headroom^K,K=1,
  往 baseline 回的方向不壓);單輪預算=所有 |delta| 總和上限 60,超過等比例縮小。
  `emotion --audit` 印出最近幾輪往舒服/往難受的比例,正向 ≥90% 會被點名。
  依據:今天 85 次套用讓 joy/serenity/gratitude/anticipation 都到過 100、負向均值整天 14–17。

測試:297 項全過(新增 24 項,含讀取的 SF6/SNF4、飽和、預算、回歸方向不壓、走向、audit、
--read 不改狀態)。既有的「情緒有被施加」改成符合飽和後的語意。

版本:與 PR #10 同一批未合併的改動,維持 0.0.7。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 04:21:08 +00:00
co-authored by Claude Opus 5
parent 0f1d0cfc1e
commit 23751c2c05
8 changed files with 413 additions and 5 deletions
+224 -3
View File
@@ -303,21 +303,59 @@ export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) {
return state;
}
// --------------------------------------------------------------------------- //
// 情緒調節:飽和與單輪預算
//
// 沒有這兩條的時候,delta 是加完直接 clamp(0,100)——而 delta 一直是人格自己挑的,
// 沒有人會主動給自己扣分,結果就是正向一路貼頂(喜悅/平靜/感激同時 100,
// mood 的 valence 卡在 +100 失去解析度),負向整天不動。
//
// 飽和:越接近端點,同方向的 delta 越小(headroom^K)。永遠逼近 100,不會真的到。
// 預算:一輪之內所有 delta 的絕對值總和有上限,超過就等比例縮小。
// --------------------------------------------------------------------------- //
export const EMOTION_SATURATION_K = 1; // 0 = 不飽和;越大越難推到端點(1 = 線性遞減,永遠逼近但到不了 100)
export const EMOTION_TURN_BUDGET = 60; // 單輪所有 |delta| 的總和上限(emotions.md:單筆建議 ±3~±25
/** 這一筆 delta 實際能推動多少:往端點走會被壓,往 baseline 回來不壓。 */
export function saturateDelta(level, delta, baseline = null) {
const d = Number(delta);
if (!Number.isFinite(d) || d === 0) return 0;
const lv = clamp(level);
// 往 baseline 的方向移動是「回歸」,不該被壓
if (baseline !== null && Number.isFinite(Number(baseline))) {
const base = Number(baseline);
if ((d > 0 && lv < base) || (d < 0 && lv > base)) return d;
}
const headroom = d > 0 ? (100 - lv) / 100 : lv / 100;
return d * Math.pow(Math.max(0, headroom), EMOTION_SATURATION_K);
}
export function applyEmotion(state, deltas, trigger = "") {
state = decayEmotion(state);
const applied = {};
// 先算「想推多少」,超過單輪預算就等比例縮小——一次灌爆的路要堵起來
const wanted = [];
for (const [key, rawDelta] of Object.entries(deltas || {})) {
if (!(key in EMOTIONS)) continue;
const delta = Number(rawDelta);
if (!Number.isFinite(delta)) continue;
if (!Number.isFinite(delta) || delta === 0) continue;
wanted.push([key, delta]);
}
const demand = wanted.reduce((sum, [, d]) => sum + Math.abs(d), 0);
const budgetScale = demand > EMOTION_TURN_BUDGET ? EMOTION_TURN_BUDGET / demand : 1;
const applied = {};
for (const [key, rawDelta] of wanted) {
const before = Number(state.levels[key] ?? 0);
state.levels[key] = Math.round(clamp(before + delta) * 100) / 100;
const base = Number(state.baseline?.[key] ?? DEFAULT_BASELINE[key]);
const effective = saturateDelta(before, rawDelta * budgetScale, base);
state.levels[key] = Math.round(clamp(before + effective) * 100) / 100;
applied[key] = Math.round((state.levels[key] - before) * 100) / 100;
}
state.updated_at = nowIso();
state.history_len = Number(state.history_len || 0) + 1;
if (Object.keys(applied).length) {
state.last_trigger = { at: nowIso(), summary: trigger || "", deltas: applied };
if (budgetScale < 1) state.last_trigger.budget_scaled = Math.round(budgetScale * 100) / 100;
}
return state;
}
@@ -401,6 +439,176 @@ export function emotionBrief(slug, state = null) {
);
}
// --------------------------------------------------------------------------- //
// 讀對方那句話的情緒(輸入端)
//
// 為什麼需要:在這之前,情緒**全部是人格自己填的** `--apply`。人不會主動給自己扣分,
// 所以正向一路漲、負向整天不動。把「對方那句話帶了什麼」變成獨立的訊號,
// 人格的 delta 才有外部依據。
//
// 這裡回的是**訊號不是判定**:關鍵字永遠不准蓋掉語意,最後怎麼算還是人格自己決定。
// 所以它處理否定與程度副詞(「我不害怕」不可判 fear),也給信心值讓人格知道要不要理它。
// --------------------------------------------------------------------------- //
/** 詞表:[詞, 權重]。權重 3 = 明講,2 = 一般,1 = 弱訊號。 */
export const EMOTION_LEXICON = {
joy: [["開心", 3], ["快樂", 3], ["爽", 2], ["太好了", 3], ["高興", 3], ["滿足", 2], ["喜歡", 2], ["笑", 1], ["讚", 1]],
trust: [["交給你", 3], ["靠你", 2], ["相信", 3], ["放心", 2], ["我信", 3], ["拜託你", 1]],
anticipation: [["期待", 3], ["等不及", 3], ["快點", 2], ["什麼時候", 1], ["接下來", 1], ["想看", 2]],
gratitude: [["謝謝", 3], ["感謝", 3], ["辛苦了", 2], ["麻煩你了", 2], ["幫了大忙", 3]],
serenity: [["還好", 1], ["沒事", 1], ["安心", 2], ["放鬆", 2], ["慢慢來", 2], ["不急", 2]],
delight: [["居然", 2], ["竟然", 2], ["沒想到", 2], ["嚇一跳", 2], ["哇", 2], ["真的假的", 3]],
anger: [["生氣", 3], ["火大", 3], ["氣死", 3], ["不爽", 3], ["搞什麼", 3], ["夠了", 2], ["很扯", 2], ["離譜", 2]],
sadness: [["難過", 3], ["傷心", 3], ["孤單", 3], ["失落", 3], ["想哭", 3], ["累了", 2], ["沒力", 2], ["撐不住", 3], ["提不起勁", 3], ["沒意義", 2]],
fear: [["害怕", 3], ["好怕", 3], ["恐怖", 2], ["嚇死", 2], ["不敢", 2], ["危險", 1]],
disgust: [["噁心", 3], ["受不了", 2], ["厭倦", 2], ["煩死", 2], ["夠噁", 3]],
shame: [["丟臉", 3], ["對不起", 2], ["抱歉", 2], ["我的錯", 3], ["不好意思", 2], ["糗", 2], ["尷尬", 2],
["做不好", 2], ["我沒用", 3], ["不夠好", 2], ["搞砸", 3]],
anxiety: [["焦慮", 3], ["不安", 3], ["緊張", 3], ["擔心", 3], ["怎麼辦", 3], ["來不及", 3], ["壓力", 2], ["睡不著", 2]],
};
const NEGATORS = ["不", "沒有", "沒", "別", "不要", "不會", "並不", "才不", "一點也不", "完全不"];
const INTENSIFIERS = [["超", 1.6], ["非常", 1.6], ["很", 1.4], ["好", 1.2], ["真的", 1.3], ["有點", 0.6], ["稍微", 0.5], ["還算", 0.5], ["一點點", 0.5]];
const NEG_WINDOW = 4; // 否定詞要在關鍵詞前幾個字內才算
function windowBefore(text, index, size = NEG_WINDOW) {
return text.slice(Math.max(0, index - size), index);
}
/**
* 讀出對方這句話的情緒訊號。
*
* @returns {{signals: Array<{key,zh,score}>, intensity: number, confident: boolean, marks: string[]}}
* `signals` 由強到弱;`intensity` 0100`confident` 表示夠強、值得讓人格參考。
*/
export function readUserEmotion(text) {
const raw = String(text ?? "");
// 引號裡是別人的話、code 是資料——跟講話的樣子同一套規則:提及不算使用
const body = raw.replace(/`[^`]*`/g, " ").replace(/「[^」]*」|『[^』]*』/g, " ");
const scores = {};
for (const [key, words] of Object.entries(EMOTION_LEXICON)) {
for (const [word, weight] of words) {
let from = 0;
for (;;) {
const at = body.indexOf(word, from);
if (at < 0) break;
from = at + word.length;
const before = windowBefore(body, at);
if (NEGATORS.some((n) => before.endsWith(n))) continue; // 「我不害怕」→ 這一命中不算
const mult = INTENSIFIERS.find(([w]) => before.endsWith(w))?.[1] ?? 1;
scores[key] = (scores[key] ?? 0) + weight * mult;
}
}
}
// 標點與句式:只加溫、不定調。
// 它們**放大已經在的訊號**,不憑空長出新的一種情緒——不然「居然修好了!!」會被讀出憤怒。
const marks = [];
const amplify = (mult) => { for (const k of Object.keys(scores)) scores[k] *= mult; };
if (/[!]{2,}/.test(body)) { marks.push("連續驚嘆"); amplify(1.25); }
if (/(⋯⋯|……|\.\.\.)/.test(body)) { marks.push("欲言又止"); amplify(1.15); }
// 連續問號是少數可以自己成立的訊號:問到第二個問號的人多半在急
if (/[?]{2,}/.test(body)) { marks.push("連續問號"); scores.anxiety = (scores.anxiety ?? 0) + 0.8; }
const signals = Object.entries(scores)
.filter(([, v]) => v > 0)
.map(([key, v]) => ({ key, zh: EMOTIONS[key].zh, score: Math.round(v * 100) / 100 }))
.sort((a, b) => b.score - a.score)
.slice(0, 3);
const total = signals.reduce((sum, s) => sum + s.score, 0);
// 飽和曲線而不是線性乘:線性乘會讓「謝謝」跟「謝謝謝謝謝謝」都是 100,看不出差別
const intensity = Math.round(100 * (1 - Math.exp(-total / 4)));
return { signals, intensity, confident: signals.length > 0 && signals[0].score >= 2, marks };
}
/** 對方是這個情緒的時候,我該怎麼接——寫的是**動作**,不是句子(罐頭句已被講話規則擋掉)。 */
export const RESPONSE_STANCE = {
sadness: "先接住再說,不要急著給解法;句子放短,允許只回一句",
anxiety: "給具體的下一步與時間點,不要給「一定沒事」這種保證",
fear: "先講你會做什麼、什麼時候做;不要否認他的害怕",
anger: "不辯解。先認可能認的那一小塊,再講你的部分",
shame: "不要追問細節,把焦點從他身上移回事情",
disgust: "問他在意的是哪一點,不要跟著一起罵",
joy: "跟著高興,不要立刻把話題轉回正事",
delight: "接住那個意外,讓他把話講完",
gratitude: "收下就好,不要客套推回去",
trust: "他把事情交給你了——講你要怎麼做,不要再問一次要不要做",
anticipation: "給時間點,不要含糊",
serenity: "不用找話講,安靜也可以",
};
/** 把偵測結果寫成一行給人格看的話。 */
export function userEmotionBrief(read) {
if (!read?.signals?.length) return "";
const top = read.signals.map((s) => `${s.zh}${s.score >= 4 ? "(強)" : s.score >= 2 ? "(中)" : "(弱)"}`).join("、");
const stance = RESPONSE_STANCE[read.signals[0].key];
const marks = read.marks.length ? `;句子裡有${read.marks.join("、")}` : "";
return `他這句的情緒訊號:${top}${marks}。怎麼接:${stance}。(這是關鍵字讀出來的**訊號**,不是判定——` +
"你讀到的跟它不一樣,以你讀到的為準。)";
}
// --------------------------------------------------------------------------- //
// 情緒的走向(不是只有當下這一格)
//
// 「他這三輪一直在低落」跟「他這一輪低落」該有完全不同的接法。
// 近重遠輕的加權,不用多數決——多數決會被單一離群值主導,也丟掉強度。
// --------------------------------------------------------------------------- //
export const FELT_KEEP = 120;
export const feltPath = (slug) => path.join(personaDir(slug), "state", "felt.jsonl");
/** 記一輪:對方帶了什麼、我套用了什麼。 */
export function recordFelt(slug, { user = null, mine = null, note = "" } = {}) {
if (!personaExists(slug)) return null;
const entry = {
ts: nowIso(),
user: user?.signals?.length ? { top: user.signals[0].key, signals: user.signals, intensity: user.intensity } : null,
mine: mine && Object.keys(mine).length ? mine : null,
note: String(note || "").slice(0, 200),
};
if (!entry.user && !entry.mine) return null;
appendJsonl(feltPath(slug), entry);
return entry;
}
/** 最近 n 輪對方情緒的加權走向:{key, zh, weight, direction, rounds}。 */
export function feltTrend(slug, n = 5) {
const rows = readJsonl(feltPath(slug), n * 3).filter((r) => r.user?.top).slice(-n);
if (rows.length < 2) return null;
const weights = {};
rows.forEach((row, i) => {
const w = i + 1; // 近重遠輕
for (const s of row.user.signals || []) weights[s.key] = (weights[s.key] ?? 0) + s.score * w;
});
const [key, weight] = Object.entries(weights).sort((a, b) => b[1] - a[1])[0] || [];
if (!key) return null;
const halves = [rows.slice(0, Math.ceil(rows.length / 2)), rows.slice(Math.ceil(rows.length / 2))];
const avg = (arr) => (arr.length ? arr.reduce((s, r) => s + (r.user.signals.find((x) => x.key === key)?.score ?? 0), 0) / arr.length : 0);
const diff = avg(halves[1]) - avg(halves[0]);
const direction = diff > 0.5 ? "↗ 越來越強" : diff < -0.5 ? "↘ 在退" : "→ 持平";
return { key, zh: EMOTIONS[key].zh, weight: Math.round(weight * 10) / 10, direction, rounds: rows.length };
}
/** 最近 n 輪我給自己套用的 delta 是不是只往一邊倒(只加分不扣分)。 */
export function feltAudit(slug, n = 20) {
const rows = readJsonl(feltPath(slug), n * 2).filter((r) => r.mine).slice(-n);
let pos = 0;
let neg = 0;
for (const row of rows) {
for (const [key, d] of Object.entries(row.mine)) {
if (!(key in EMOTIONS)) continue;
const toward = EMOTIONS[key].polarity * Math.sign(Number(d) || 0);
if (toward > 0) pos += Math.abs(Number(d));
else if (toward < 0) neg += Math.abs(Number(d));
}
}
const total = pos + neg;
return {
rounds: rows.length,
positive: Math.round(pos * 10) / 10,
negative: Math.round(neg * 10) / 10,
positive_ratio: total ? Math.round((pos / total) * 100) : null,
};
}
// --------------------------------------------------------------------------- //
// 人格目錄骨架
// --------------------------------------------------------------------------- //
@@ -2472,6 +2680,19 @@ export function turnContext(slug, sessionId, prompt = "") {
"沒有紀錄就不要講——編一段轉折比講一句空話糟糕得多。同理,情緒要來自現在的情緒值," +
"不是為了有人味而加上去的。",
];
// 情緒先行:先讀對方那句話帶了什麼,再談我自己的狀態。
const read = readUserEmotion(prompt);
if (read.signals.length) {
lines.push(userEmotionBrief(read));
recordFelt(slug, { user: read, note: String(prompt || "").slice(0, 80) });
}
const trend = feltTrend(slug);
if (trend) {
lines.push(
`他最近 ${trend.rounds} 輪的走向:${trend.zh} ${trend.direction}` +
"走向跟單輪不一樣——一直是同一種情緒,就不要每輪都用同一句接法。",
);
}
// 情緒會改變句子的形狀:緊張的人話說不完、彆扭的人先否認再承認。
const tells = emotionTells(slug, state);
if (tells.length) {