Files
Kokorone/scripts/smoke/D.mjs
T
Jeffery 611c9b400c 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 群組冒煙測試無回歸。
2026-08-13 10:18:58 +08:00

143 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}