feat: 完成 D 群組 — 情緒子系統

- RuleBasedEmotionTagger:關鍵字比對輸出情緒類型與強度(衝突/稱讚/輕度負向/一般負向/正向五級優先序)
- EmotionService:以資料庫 EmotionState 為單一事實來源,dominantState 取非平靜維度最高者,
  半衰期時間衰減(預設 2 小時,可覆寫),狀態轉移時清零其餘維度只設定目標維度
- emotion-state-machine:只實作 wiki 狀態圖與可愛度設計明訂的邊(平靜/愉悅/低落/警戒/害羞/彆扭)
- response-style:情緒 → 語氣/句長/主動性,供 F 群組上下文組裝器使用
- apps/api 新增 /emotion/* 端點作為引擎驗證介面
- scripts/smoke/D.mjs:涵蓋 D-1~D-4 全部轉移邊、回應風格對應、時間衰減與跨 session 殘留驗證

npm run restart && npm run smoke -- D 皆通過(D-V),A/B/C 群組冒煙測試無回歸。
This commit is contained in:
Jeffery
2026-08-13 10:18:58 +08:00
parent e6f25008d1
commit 611c9b400c
9 changed files with 483 additions and 6 deletions
+142
View File
@@ -0,0 +1,142 @@
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const CHARACTER_ID = "seed-character-genki";
const ORIGINAL_EMOTION = { calm: 70, joy: 20, sad: 0, alert: 0, shy: 0, grumpy: 0 };
const BASELINE = { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0 };
async function resetEmotion(overrides = BASELINE) {
await prisma.emotionState.upsert({
where: { characterId: CHARACTER_ID },
update: { ...overrides, updatedAt: new Date() },
create: { characterId: CHARACTER_ID, ...overrides },
});
}
async function postInput(body) {
const res = await fetch(`http://localhost:${API_PORT}/emotion/${CHARACTER_ID}/input`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`POST /emotion/${CHARACTER_ID}/input 回傳 ${res.status}`);
}
return res.json();
}
async function getState(now) {
const url = `http://localhost:${API_PORT}/emotion/${CHARACTER_ID}${now ? `?now=${encodeURIComponent(now)}` : ""}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`GET /emotion/${CHARACTER_ID} 回傳 ${res.status}`);
}
return res.json();
}
function dominantOf(state) {
const candidates = [
["JOY", state.joy],
["SAD", state.sad],
["ALERT", state.alert],
["SHY", state.shy],
["GRUMPY", state.grumpy],
];
const [tag, value] = candidates.reduce((best, cur) => (cur[1] > best[1] ? cur : best), ["CALM", 5]);
return value > 5 ? tag : "CALM";
}
function assertDominant(state, expected, label) {
const actual = dominantOf(state);
if (actual !== expected) {
throw new Error(`${label}:預期主導情緒為 ${expected},實際為 ${actual}(${JSON.stringify(state)})`);
}
}
export default async function smokeD() {
// D-1 情緒標記器:正向/負向/衝突語氣輸入分別得到對應標記
await resetEmotion();
const positive = await postInput({ text: "謝謝你,我好開心!" });
assertDominant(positive.state, "JOY", "正向輸入");
await resetEmotion();
const negative = await postInput({ text: "今天過得好難過,很失望" });
assertDominant(negative.state, "SAD", "負向輸入");
await resetEmotion();
const conflict = await postInput({ text: "你這個笨蛋,滾開!" });
assertDominant(conflict.state, "ALERT", "衝突語氣輸入");
if (conflict.style.sentenceLength !== "short" || conflict.style.initiative !== "low") {
throw new Error("D-3 警戒狀態的回應風格參數不符預期");
}
// D-4 情緒狀態機:連續負向(衝突持續)輸入 → 警戒繼續惡化為低落
const stillConflicted = await postInput({ text: "我還是很生氣", conflictContinues: true });
assertDominant(stillConflicted.state, "SAD", "警戒 --> 低落(衝突持續)");
// 警戒 --> 平靜(確認無威脅)
await resetEmotion({ calm: 0, joy: 0, sad: 0, alert: 100, shy: 0, grumpy: 0 });
const safeAgain = await postInput({ text: "沒事了", threatConfirmedSafe: true });
assertDominant(safeAgain.state, "CALM", "警戒 --> 平靜(確認無威脅)");
// 低落 --> 平靜(被安慰)
await resetEmotion({ calm: 50, joy: 0, sad: 50, alert: 0, shy: 0, grumpy: 0 });
const comforted = await postInput({ text: "謝謝你安慰我", comforted: true });
assertDominant(comforted.state, "CALM", "低落 --> 平靜(被安慰)");
// 平靜 --> 害羞(被稱讚)
await resetEmotion();
const praised = await postInput({ text: "你今天好可愛喔" });
assertDominant(praised.state, "SHY", "平靜 --> 害羞(被稱讚)");
// 平靜 --> 彆扭(小型負向事件)→ 被哄後快速恢復平靜
await resetEmotion();
const grumpy = await postInput({ text: "你都不理我" });
assertDominant(grumpy.state, "GRUMPY", "平靜 --> 彆扭(小型負向事件)");
const placated = await postInput({ text: "好啦好啦", placated: true });
assertDominant(placated.state, "CALM", "彆扭 --> 平靜(被哄)");
// D-3 情緒→回應風格輸出:愉悅指向多話/高主動,低落指向簡短/低主動
await resetEmotion();
const joyful = await postInput({ text: "謝謝你,我好開心!" });
if (joyful.style.sentenceLength !== "long" || joyful.style.initiative !== "high") {
throw new Error("D-3 愉悅狀態的回應風格參數不符預期");
}
await resetEmotion();
const sadResult = await postInput({ text: "今天過得好難過,很失望" });
if (sadResult.style.sentenceLength !== "short" || sadResult.style.initiative !== "low") {
throw new Error("D-3 低落狀態的回應風格參數不符預期");
}
// D-2 時間衰減:模擬經過數小時後情緒值回落;跨 session(重新讀取)仍可讀到殘留
await resetEmotion({ calm: 0, joy: 100, sad: 0, alert: 0, shy: 0, grumpy: 0 });
const immediate = await getState();
if (immediate.joy < 90) {
throw new Error("尚未經過時間就已衰減,時間衰減函數計算有誤");
}
const threeHoursLater = new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString();
const decayed = await getState(threeHoursLater);
if (decayed.joy >= immediate.joy) {
throw new Error("模擬時間推進後情緒值未回落");
}
if (decayed.joy <= 0) {
throw new Error("時間推進後情緒殘留應仍大於 0(尚未完全衰減)");
}
// 跨 session:不重啟服務也不清狀態,重新 GET 一次應讀到同一份殘留(持久化於資料庫,而非行程記憶體)
const reread = await getState(threeHoursLater);
if (Math.abs(reread.joy - decayed.joy) > 0.01) {
throw new Error("重新讀取時殘留情緒值不一致,情緒狀態未正確持久化");
}
const twentyHoursLater = new Date(Date.now() + 20 * 60 * 60 * 1000).toISOString();
const fullyDecayed = await getState(twentyHoursLater);
if (dominantOf(fullyDecayed) !== "CALM") {
throw new Error("長時間衰減後應回歸平靜");
}
// 還原種子角色原始展示用情緒值,避免影響其他群組或人工檢視
await resetEmotion(ORIGINAL_EMOTION);
}