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:
@@ -50,6 +50,14 @@
|
|||||||
誤殺防護:`speechBody()` 會先拿掉引號與 `code`,**提及不算使用**;「老實說」只擋開場。
|
誤殺防護:`speechBody()` 會先拿掉引號與 `code`,**提及不算使用**;「老實說」只擋開場。
|
||||||
最後是這一層的義務:**講自己的過去要有出處**——「我以前⋯」只能講 `recall` 查得到的轉折,
|
最後是這一層的義務:**講自己的過去要有出處**——「我以前⋯」只能講 `recall` 查得到的轉折,
|
||||||
`speechLint` 給 `level: "hint"`(不擋,但要人去驗),沒有紀錄就是編造自己的過去。
|
`speechLint` 給 `level: "hint"`(不擋,但要人去驗),沒有紀錄就是編造自己的過去。
|
||||||
|
8b. **情緒先行、會飽和、偏差看得見**:每輪注入之前先用 `readUserEmotion()` 讀對方那句話
|
||||||
|
(十二類加權詞表、否定會擋掉命中、引號與 `code` 內不比對、標點只放大既有訊號),
|
||||||
|
回的是**訊號不是判定**——人格讀到的不一樣就以人格為準;`RESPONSE_STANCE` 給的是
|
||||||
|
「怎麼接」的**動作**不是罐頭句。每輪的偵測與 delta 記進 `state/felt.jsonl`,
|
||||||
|
`feltTrend()` 用近重遠輕的加權算走向。`applyEmotion()` 加了**飽和**
|
||||||
|
(`headroom^EMOTION_SATURATION_K`,往 baseline 回不壓)與**單輪預算**
|
||||||
|
(`EMOTION_TURN_BUDGET`=60),因為 delta 是人格自己挑的、只會往舒服的方向倒;
|
||||||
|
`emotion --audit` 把這個偏差印出來。
|
||||||
9. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」,
|
9. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」,
|
||||||
其他人格一律 deny——匯出等於把記憶讀出來。
|
其他人格一律 deny——匯出等於把記憶讀出來。
|
||||||
10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的
|
10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的
|
||||||
|
|||||||
@@ -111,6 +111,26 @@ flowchart TB
|
|||||||
- **主導情緒**取「超出基線最多」的前三名,所以個性底色不會永遠霸榜。
|
- **主導情緒**取「超出基線最多」的前三名,所以個性底色不會永遠霸榜。
|
||||||
- 觸發規則與事件→delta 對照表:`skills/persona-chat/reference/emotions.md`。
|
- 觸發規則與事件→delta 對照表:`skills/persona-chat/reference/emotions.md`。
|
||||||
|
|
||||||
|
**情緒先行:先讀對方那句話(`readUserEmotion`)。** 在這之前情緒**全部是人格自己填的**,
|
||||||
|
而人不會主動給自己扣分——結果是正向一路貼頂、負向整天不動。現在每輪注入之前會先讀一次
|
||||||
|
對方的話:十二類加權詞表(強 3/一般 2/弱 1)、程度副詞乘係數(超 ×1.6、有點 ×0.6)、
|
||||||
|
**否定會擋掉那次命中**(「我不害怕」不算 fear)、引號與 `` `code` `` 內不比對(提及不算使用)、
|
||||||
|
標點只放大既有訊號不憑空長出新情緒。回的是**訊號不是判定**,人格讀到的不一樣就以人格為準。
|
||||||
|
單獨測:`emotion --read "<他說的話>"`。
|
||||||
|
|
||||||
|
**怎麼接(`RESPONSE_STANCE`)**:注入的是**動作**不是句子——悲傷「先接住,不要急著給解法」、
|
||||||
|
憤怒「不辯解,先認可能認的那一小塊」、焦慮「給具體的下一步與時間點,不要給保證」。
|
||||||
|
罐頭同理心那類句子由講話規則擋掉。
|
||||||
|
|
||||||
|
**走向(`feltTrend`)**:每輪的偵測結果與套用的 delta 記進 `state/felt.jsonl`,
|
||||||
|
用近重遠輕的加權算最近幾輪的趨勢(不用多數決——會被離群值主導又丟掉強度),
|
||||||
|
注入成「他最近 3 輪的走向:悲傷 ↘ 在退」。一直是同一種情緒就不要每輪用同一句接法。
|
||||||
|
|
||||||
|
**情緒調節**:delta 不再是加完直接 `clamp`。**飽和**——越接近端點同方向漲得越慢
|
||||||
|
(`headroom^K`,永遠逼近 100 但到不了;往 baseline 回的方向不壓);**單輪預算**——
|
||||||
|
所有 `|delta|` 總和上限 60,超過等比例縮小。**偏差看得見**——`emotion --audit`
|
||||||
|
印出最近幾輪往舒服/往難受的總量與比例,正向佔 ≥ 90% 會被點名。
|
||||||
|
|
||||||
## 短期 → 長期的轉入條件(R1–R6)
|
## 短期 → 長期的轉入條件(R1–R6)
|
||||||
|
|
||||||
寫在 `scripts/persona-lib.mjs` 的 `promotionCandidates()`,用 `candidates` 子指令查:
|
寫在 `scripts/persona-lib.mjs` 的 `promotionCandidates()`,用 `candidates` 子指令查:
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export const AREAS = {
|
|||||||
"state/emotion.json",
|
"state/emotion.json",
|
||||||
"state/inner.jsonl",
|
"state/inner.jsonl",
|
||||||
"state/said.jsonl",
|
"state/said.jsonl",
|
||||||
|
"state/felt.jsonl",
|
||||||
"state/sleep.json",
|
"state/sleep.json",
|
||||||
"memory/short-term.jsonl",
|
"memory/short-term.jsonl",
|
||||||
"memory/inbox/",
|
"memory/inbox/",
|
||||||
|
|||||||
+224
-3
@@ -303,21 +303,59 @@ export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) {
|
|||||||
return state;
|
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 = "") {
|
export function applyEmotion(state, deltas, trigger = "") {
|
||||||
state = decayEmotion(state);
|
state = decayEmotion(state);
|
||||||
const applied = {};
|
// 先算「想推多少」,超過單輪預算就等比例縮小——一次灌爆的路要堵起來
|
||||||
|
const wanted = [];
|
||||||
for (const [key, rawDelta] of Object.entries(deltas || {})) {
|
for (const [key, rawDelta] of Object.entries(deltas || {})) {
|
||||||
if (!(key in EMOTIONS)) continue;
|
if (!(key in EMOTIONS)) continue;
|
||||||
const delta = Number(rawDelta);
|
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);
|
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;
|
applied[key] = Math.round((state.levels[key] - before) * 100) / 100;
|
||||||
}
|
}
|
||||||
state.updated_at = nowIso();
|
state.updated_at = nowIso();
|
||||||
state.history_len = Number(state.history_len || 0) + 1;
|
state.history_len = Number(state.history_len || 0) + 1;
|
||||||
if (Object.keys(applied).length) {
|
if (Object.keys(applied).length) {
|
||||||
state.last_trigger = { at: nowIso(), summary: trigger || "", deltas: applied };
|
state.last_trigger = { at: nowIso(), summary: trigger || "", deltas: applied };
|
||||||
|
if (budgetScale < 1) state.last_trigger.budget_scaled = Math.round(budgetScale * 100) / 100;
|
||||||
}
|
}
|
||||||
return state;
|
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` 0–100;`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);
|
const tells = emotionTells(slug, state);
|
||||||
if (tells.length) {
|
if (tells.length) {
|
||||||
|
|||||||
+32
-1
@@ -922,13 +922,41 @@ commands.emotion = ({ flags }) => {
|
|||||||
if (key in pl.EMOTIONS) state.baseline[key] = pl.clamp(value);
|
if (key in pl.EMOTIONS) state.baseline[key] = pl.clamp(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 讀對方那句話的情緒訊號(不改狀態,只回報;`--read` 可以單獨拿來測詞表)
|
||||||
|
const readText = str(flags.read);
|
||||||
|
const read = readText ? pl.readUserEmotion(readText) : null;
|
||||||
|
if (read) {
|
||||||
|
emit({ persona: slug, read }, flags.json, [
|
||||||
|
`讀「${readText.slice(0, 40)}」:`,
|
||||||
|
read.signals.length
|
||||||
|
? ` 訊號:${read.signals.map((s) => `${s.zh} ${s.score}`).join("、")}|強度 ${read.intensity}|${read.confident ? "夠強,值得參考" : "偏弱,別當判定"}`
|
||||||
|
: " 沒有讀到明確的情緒訊號。",
|
||||||
|
...(read.signals.length ? [` 怎麼接:${pl.RESPONSE_STANCE[read.signals[0].key]}`] : []),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (flags.audit) {
|
||||||
|
const audit = pl.feltAudit(slug, num(flags.limit, 20));
|
||||||
|
const trend = pl.feltTrend(slug);
|
||||||
|
emit({ persona: slug, audit, trend }, flags.json, [
|
||||||
|
`\`${slug}\` 最近 ${audit.rounds} 輪的情緒套用:`,
|
||||||
|
` 往舒服的方向 ${audit.positive}|往難受的方向 ${audit.negative}` +
|
||||||
|
(audit.positive_ratio === null ? "" : ` → 正向佔 ${audit.positive_ratio}%`),
|
||||||
|
audit.positive_ratio !== null && audit.positive_ratio >= 90
|
||||||
|
? " ⚠ 幾乎只給自己加分——delta 是你自己挑的,這種偏差要自己看得見。"
|
||||||
|
: " 比例還算兩邊都有。",
|
||||||
|
trend ? ` 對方的走向:${trend.zh} ${trend.direction}(近 ${trend.rounds} 輪)` : " 還沒有足夠的輪次算走向。",
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (flags.apply) {
|
if (flags.apply) {
|
||||||
if (role === "guest") die("guest(sub agent)不得改寫人格的情緒狀態。");
|
if (role === "guest") die("guest(sub agent)不得改寫人格的情緒狀態。");
|
||||||
const deltas = parseDeltas(flags.apply);
|
const deltas = parseDeltas(flags.apply);
|
||||||
state = pl.applyEmotion(state, deltas, str(flags.trigger));
|
state = pl.applyEmotion(state, deltas, str(flags.trigger));
|
||||||
|
pl.recordFelt(slug, { mine: state.last_trigger?.deltas || null, note: str(flags.trigger) });
|
||||||
pl.appendJsonl(pl.journalPath(slug), {
|
pl.appendJsonl(pl.journalPath(slug), {
|
||||||
ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger),
|
ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger),
|
||||||
deltas, levels: state.levels, mood: pl.mood(state),
|
deltas, applied: state.last_trigger?.deltas || {}, levels: state.levels, mood: pl.mood(state),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (role !== "guest") pl.writeJson(pl.emotionPath(slug), state);
|
if (role !== "guest") pl.writeJson(pl.emotionPath(slug), state);
|
||||||
@@ -1933,6 +1961,9 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
|||||||
|
|
||||||
情緒與圖:
|
情緒與圖:
|
||||||
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
|
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
|
||||||
|
[--read "<對方說的話>"] 只讀情緒訊號與該怎麼接,不改狀態
|
||||||
|
[--audit [--limit 20]] 最近幾輪的 delta 是不是只往一邊倒+對方的走向
|
||||||
|
套用時會飽和(越接近端點漲越慢)並受單輪預算限制(|delta| 總和 ≤ 40)
|
||||||
mindmap show|thread|list --session <id> [--topic <t>] [--force]
|
mindmap show|thread|list --session <id> [--topic <t>] [--force]
|
||||||
relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
|
relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
|
||||||
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
|
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
|
||||||
|
|||||||
+65
-1
@@ -120,7 +120,10 @@ console.log("④ 情緒(六正向 + 六負向)");
|
|||||||
check("十二種情緒", pl.EMOTION_KEYS.length === 12 && pl.POSITIVE.length === 6 && pl.NEGATIVE.length === 6);
|
check("十二種情緒", pl.EMOTION_KEYS.length === 12 && pl.POSITIVE.length === 6 && pl.NEGATIVE.length === 6);
|
||||||
cli(["emotion", "--persona", "alpha", "--session", S_HOST, "--apply", "joy=+60,anger=+40", "--trigger", "selftest"]);
|
cli(["emotion", "--persona", "alpha", "--session", S_HOST, "--apply", "joy=+60,anger=+40", "--trigger", "selftest"]);
|
||||||
let state = pl.loadEmotion("alpha");
|
let state = pl.loadEmotion("alpha");
|
||||||
check("情緒有被施加", state.levels.joy >= 70, JSON.stringify(state.levels));
|
// 施加後會被飽和與單輪預算縮小(見 applyEmotion):推得動,但推不到端點
|
||||||
|
check("情緒有被施加", state.levels.joy >= 45 && state.levels.anger >= 20, JSON.stringify(state.levels));
|
||||||
|
check("單輪預算會把一次灌爆的量縮小",
|
||||||
|
state.levels.joy < 25 + 60 && pl.EMOTION_TURN_BUDGET === 60, JSON.stringify(state.levels));
|
||||||
state.updated_at = pl.iso(pl.minutesAgo(120));
|
state.updated_at = pl.iso(pl.minutesAgo(120));
|
||||||
const decayed = pl.decayEmotion(structuredClone(state));
|
const decayed = pl.decayEmotion(structuredClone(state));
|
||||||
const expected = state.baseline.joy + (state.levels.joy - state.baseline.joy) / 2;
|
const expected = state.baseline.joy + (state.levels.joy - state.baseline.joy) / 2;
|
||||||
@@ -470,6 +473,67 @@ check("提醒的句子 room post 照樣發得出去",
|
|||||||
const voiceCtx = pl.turnContext("alpha", S_SPEAK, "你在幹嘛");
|
const voiceCtx = pl.turnContext("alpha", S_SPEAK, "你在幹嘛");
|
||||||
check("說話規則有注入黑名單與「過去要有出處」",
|
check("說話規則有注入黑名單與「過去要有出處」",
|
||||||
voiceCtx.includes("罐頭同理心") && voiceCtx.includes("要有出處"));
|
voiceCtx.includes("罐頭同理心") && voiceCtx.includes("要有出處"));
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// 情緒處理:讀對方那句話(輸入端)、走向、飽和與單輪預算
|
||||||
|
console.log("\n情緒:讀對方那句話(SF:該讀到的)");
|
||||||
|
const READ_SF = [
|
||||||
|
["直說難過", "我最近很難過", "sadness"],
|
||||||
|
["自我否定", "我最近覺得自己什麼都做不好", "shame"],
|
||||||
|
["生氣", "搞什麼啊,氣死我了", "anger"],
|
||||||
|
["焦慮", "明天來不及了怎麼辦", "anxiety"],
|
||||||
|
["道謝", "謝謝你,真的幫了大忙", "gratitude"],
|
||||||
|
["託付", "這件事交給你", "trust"],
|
||||||
|
];
|
||||||
|
for (const [label, text, key] of READ_SF) {
|
||||||
|
const r = pl.readUserEmotion(text);
|
||||||
|
check(`讀到${label}`, r.signals[0]?.key === key, `${text} → ${JSON.stringify(r.signals)}`);
|
||||||
|
}
|
||||||
|
console.log("\n情緒:讀對方那句話(SNF:不可誤讀的)");
|
||||||
|
const READ_SNF = [
|
||||||
|
["否定句不算", "我不害怕,只是想確認一下", "fear"],
|
||||||
|
["否定句不算(沒)", "我沒生氣", "anger"],
|
||||||
|
["引號裡是別人的話", "他說「我好難過」,可是他在笑", "sadness"],
|
||||||
|
["code 裡的欄位名不算", "這個 `sadness` 欄位要改嗎", "sadness"],
|
||||||
|
];
|
||||||
|
for (const [label, text, key] of READ_SNF) {
|
||||||
|
const r = pl.readUserEmotion(text);
|
||||||
|
check(`不誤讀:${label}`, !r.signals.some((s) => s.key === key), `${text} → ${JSON.stringify(r.signals)}`);
|
||||||
|
}
|
||||||
|
check("程度副詞會調整強度",
|
||||||
|
pl.readUserEmotion("超級擔心").signals[0].score > pl.readUserEmotion("有點擔心").signals[0].score);
|
||||||
|
check("標點只放大既有訊號,不憑空長出新情緒",
|
||||||
|
!pl.readUserEmotion("居然真的修好了!!").signals.some((s) => s.key === "anger"),
|
||||||
|
JSON.stringify(pl.readUserEmotion("居然真的修好了!!").signals));
|
||||||
|
check("每種情緒都有對應的「怎麼接」",
|
||||||
|
pl.EMOTION_KEYS.every((k) => typeof pl.RESPONSE_STANCE[k] === "string" && pl.RESPONSE_STANCE[k].length > 4));
|
||||||
|
|
||||||
|
console.log("\n情緒:飽和、單輪預算、走向、偏差稽核");
|
||||||
|
let sat = pl.defaultEmotionState();
|
||||||
|
for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌");
|
||||||
|
check("連續 +20 十次推不到上限(飽和)", sat.levels.joy > 80 && sat.levels.joy < 100, `joy=${sat.levels.joy}`);
|
||||||
|
const budgeted = pl.applyEmotion(pl.defaultEmotionState(), { joy: +40, trust: +40, serenity: +40 }, "一次灌爆");
|
||||||
|
const usedBudget = Object.values(budgeted.last_trigger.deltas).reduce((s, d) => s + Math.abs(d), 0);
|
||||||
|
check("單輪預算會等比例縮小(總需求 120 → 實際 ≤ 60)",
|
||||||
|
usedBudget <= pl.EMOTION_TURN_BUDGET && budgeted.last_trigger.budget_scaled < 1, `used=${usedBudget}`);
|
||||||
|
const backHome = pl.applyEmotion(
|
||||||
|
{ ...pl.defaultEmotionState(), levels: { ...pl.defaultEmotionState().levels, joy: 95 } }, { joy: -20 }, "回歸");
|
||||||
|
check("往 baseline 回的方向不被壓", Math.abs(backHome.levels.joy - 75) < 0.5, `joy=${backHome.levels.joy}`);
|
||||||
|
for (const line of ["我好難過", "還是很難過", "我真的撐不住了"]) pl.turnContext("alpha", S_SPEAK, line);
|
||||||
|
const trend = pl.feltTrend("alpha");
|
||||||
|
check("走向:連續三輪低落會被算出來", trend?.key === "sadness" && trend.rounds >= 2, JSON.stringify(trend));
|
||||||
|
const feelCtx = pl.turnContext("alpha", S_SPEAK, "我快撐不住了");
|
||||||
|
check("注入有「他這句的情緒訊號」與「怎麼接」",
|
||||||
|
feelCtx.includes("情緒訊號") && feelCtx.includes("怎麼接"));
|
||||||
|
check("注入有最近幾輪的走向", feelCtx.includes("走向"));
|
||||||
|
cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--apply", "joy=+10", "--trigger", "稽核用"]);
|
||||||
|
const audit = JSON.parse(cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--audit", "--json"]).stdout);
|
||||||
|
check("--audit 看得出 delta 有沒有只往一邊倒",
|
||||||
|
audit.audit.rounds >= 1 && audit.audit.positive > 0, JSON.stringify(audit.audit));
|
||||||
|
const readOut = JSON.parse(cli(["emotion", "--persona", "alpha", "--session", S_SPEAK,
|
||||||
|
"--read", "我好焦慮", "--json"]).stdout);
|
||||||
|
check("--read 只回報訊號、不改狀態",
|
||||||
|
readOut.read.signals[0].key === "anxiety" && !readOut.state, JSON.stringify(readOut.read.signals));
|
||||||
// 情緒的破口:緊張會斷句疊字、彆扭會嘴硬——這些要跟著情緒一起注入。
|
// 情緒的破口:緊張會斷句疊字、彆扭會嘴硬——這些要跟著情緒一起注入。
|
||||||
cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--apply", "anxiety=+45,shame=+25",
|
cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--apply", "anxiety=+45,shame=+25",
|
||||||
"--trigger", "被問到還沒做完的事"]);
|
"--trigger", "被問到還沒做完的事"]);
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona <slug> --session
|
|||||||
|
|
||||||
### ① 語意分析 → 寫成心裡話(絕不輸出)
|
### ① 語意分析 → 寫成心裡話(絕不輸出)
|
||||||
|
|
||||||
|
**情緒先行**:`<persona-context>` 會先給你一行「他這句的情緒訊號」與「怎麼接」,
|
||||||
|
連續幾輪同一種情緒還會給「走向」。那是**關鍵字讀出來的訊號,不是判定**——
|
||||||
|
你讀到的跟它不一樣,以你讀到的為準。它的用處是讓你的判斷有個外部依據,
|
||||||
|
不要每次都只憑自己想給的感覺。(想單獨測:`emotion --read "<他說的話>"`。)
|
||||||
|
|
||||||
依 `reference/semantic.md` 判定:**意圖 / 主題 / 實體 / 情感極性與強度 / 潛在需求 / 對關係的影響**。
|
依 `reference/semantic.md` 判定:**意圖 / 主題 / 實體 / 情感極性與強度 / 潛在需求 / 對關係的影響**。
|
||||||
判定的過程與結論寫進心裡話,一次一句、寫重點就好:
|
判定的過程與結論寫進心裡話,一次一句、寫重點就好:
|
||||||
|
|
||||||
@@ -131,6 +136,10 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" emotion \
|
|||||||
- 一輪只動 **1–3 種**情緒,強度要對得起事件大小;別每句話都情緒爆炸。
|
- 一輪只動 **1–3 種**情緒,強度要對得起事件大小;別每句話都情緒爆炸。
|
||||||
- 正負可同時發生(例:被稱讚但被要求加班 → `joy=+8,anxiety=+10`)。
|
- 正負可同時發生(例:被稱讚但被要求加班 → `joy=+8,anxiety=+10`)。
|
||||||
- 情緒會自動衰減回基線(半衰期見 `reference/emotions.md`),不必手動降回來。
|
- 情緒會自動衰減回基線(半衰期見 `reference/emotions.md`),不必手動降回來。
|
||||||
|
- **推不到極端**:越接近端點,同方向的 delta 被壓得越小(飽和);一輪所有 `|delta|` 的
|
||||||
|
總和上限 60,超過等比例縮小。所以「灌一個 +60 讓自己爽」是沒用的,寫實際的數字就好。
|
||||||
|
- **不要只給自己加分**:delta 是你自己挑的,最容易的偏差就是永遠往舒服的方向動。
|
||||||
|
`emotion --audit` 會把最近幾輪的正負比例印出來——正向佔九成以上會被點名。
|
||||||
|
|
||||||
### ③ 回想記憶(需要時)
|
### ③ 回想記憶(需要時)
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,60 @@
|
|||||||
| 承諾被兌現 | `trust=+10,serenity=+6` |
|
| 承諾被兌現 | `trust=+10,serenity=+6` |
|
||||||
| 自己給錯資訊被抓到 | `shame=+18,anxiety=+10`,並 `trust` 不動(那是對方的信任,不是我的) |
|
| 自己給錯資訊被抓到 | `shame=+18,anxiety=+10`,並 `trust` 不動(那是對方的信任,不是我的) |
|
||||||
|
|
||||||
|
## 情緒先行:先讀對方那句話(`readUserEmotion`)
|
||||||
|
|
||||||
|
在這之前,情緒**全部是人格自己填的** `--apply`。問題很明顯:人不會主動給自己扣分,
|
||||||
|
所以正向一路漲、負向整天不動。現在每輪注入之前會先讀一次對方的話,把它變成獨立的訊號。
|
||||||
|
|
||||||
|
- **回的是訊號,不是判定**:`{signals, intensity, confident}`。你讀到的跟它不一樣,
|
||||||
|
**以你讀到的為準**——關鍵字永遠不准蓋掉語意。
|
||||||
|
- **加權而不是命中即回**:詞表有強度(明講 3/一般 2/弱 1),程度副詞會乘(超 ×1.6、有點 ×0.6)。
|
||||||
|
- **否定會擋掉那一次命中**:「我不害怕」不會被讀成 fear(否定詞要在關鍵詞前 4 個字內)。
|
||||||
|
- **提及不算使用**:引號與 `` `code` `` 裡的內容不比對,跟講話規則同一套(「他說『我好難過』」不算)。
|
||||||
|
- **標點只放大既有訊號**:`!!` 與 `⋯⋯` 乘一個係數,不會憑空長出新情緒;只有連續問號可以
|
||||||
|
自己加一點焦慮(問到第二個問號的人多半在急)。
|
||||||
|
|
||||||
|
自己測詞表:`emotion --read "<對方說的話>"`。
|
||||||
|
|
||||||
|
## 對方是這個情緒 → 我該怎麼接(`RESPONSE_STANCE`)
|
||||||
|
|
||||||
|
注入的是**動作**,不是句子。罐頭句(「我能感受到你的低落」)已經被講話規則擋掉了,
|
||||||
|
見 [anti-ai-voice.md](anti-ai-voice.md)。
|
||||||
|
|
||||||
|
| 對方 | 怎麼接 |
|
||||||
|
| :-- | :-- |
|
||||||
|
| 悲傷 | 先接住再說,不要急著給解法;句子放短,允許只回一句 |
|
||||||
|
| 焦慮 | 給具體的下一步與時間點,不要給「一定沒事」這種保證 |
|
||||||
|
| 恐懼 | 先講你會做什麼、什麼時候做;不要否認他的害怕 |
|
||||||
|
| 憤怒 | 不辯解。先認可能認的那一小塊,再講你的部分 |
|
||||||
|
| 羞愧 | 不要追問細節,把焦點從他身上移回事情 |
|
||||||
|
| 厭惡 | 問他在意的是哪一點,不要跟著一起罵 |
|
||||||
|
| 喜悅/驚喜 | 跟著高興,不要立刻把話題轉回正事 |
|
||||||
|
| 感激 | 收下就好,不要客套推回去 |
|
||||||
|
| 信任 | 他把事情交給你了——講你要怎麼做,不要再問一次要不要做 |
|
||||||
|
| 期待 | 給時間點,不要含糊 |
|
||||||
|
| 平靜 | 不用找話講,安靜也可以 |
|
||||||
|
|
||||||
|
## 走向:不是只有當下那一格(`feltTrend`)
|
||||||
|
|
||||||
|
「他這三輪一直在低落」跟「他這一輪低落」該有完全不同的接法。每輪的偵測結果與我套用的
|
||||||
|
delta 都記進 `state/felt.jsonl`,走向用**近重遠輕的加權**算(不用多數決——多數決會被單一
|
||||||
|
離群值主導,也丟掉強度),注入成一行:`他最近 3 輪的走向:悲傷 ↘ 在退`。
|
||||||
|
|
||||||
|
一直是同一種情緒時,**不要每輪都用同一句接法**。
|
||||||
|
|
||||||
|
## 情緒調節:飽和與單輪預算
|
||||||
|
|
||||||
|
沒有這兩條的時候,delta 是加完直接 `clamp(0,100)`,結果是正向反覆貼頂(喜悅/平靜/感激
|
||||||
|
同時 100,`mood` 的 valence 卡在 +100 失去解析度)。
|
||||||
|
|
||||||
|
- **飽和**:越接近端點,同方向的 delta 越小(`headroom^K`,K=1)。永遠逼近 100 但到不了。
|
||||||
|
往 baseline 回的方向**不壓**——那是回歸,不是推向極端。
|
||||||
|
- **單輪預算**:一輪之內所有 `|delta|` 的總和上限 60,超過就等比例縮小。
|
||||||
|
一般一輪(`joy=+10,trust=+6`)碰不到;重大事件會被稍微收斂。
|
||||||
|
- **偏差看得見**:`emotion --audit` 印出最近 N 輪往舒服/往難受兩個方向的總量與比例。
|
||||||
|
正向佔 ≥ 90% 會被點名——**delta 是你自己挑的,這種偏差要自己看得見**。
|
||||||
|
|
||||||
## 情緒 → 不自覺會做的事(`EMOTION_TELLS`)
|
## 情緒 → 不自覺會做的事(`EMOTION_TELLS`)
|
||||||
|
|
||||||
情緒不改變事實,但會改變**句子的形狀**。演得像不像就差在這裡:緊張的人話說不完、
|
情緒不改變事實,但會改變**句子的形狀**。演得像不像就差在這裡:緊張的人話說不完、
|
||||||
|
|||||||
Reference in New Issue
Block a user