- 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 群組冒煙測試無回歸。
143 lines
6.0 KiB
JavaScript
143 lines
6.0 KiB
JavaScript
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);
|
||
}
|