- ScheduleService:I-1 平日/週末/假期時段表(可為角色寫入高中生預設作息)、 I-3 時間感知(Asia/Taipei 當地時間,不受伺服器時區影響)、剛醒/睡前偵測 - constants.ts:I-2 時段狀態 → 回應可用度對照(FREE/SEMI_BUSY/BUSY/SLEEP → NORMAL/BRIEF/DELAYED/NO_REPLY),沒有作息表的角色一律視為 NORMAL(相容既有角色) - ScheduleExceptionService:I-4 三種破例(深夜還陪你聊/上課偷回訊息/為你調整行程), 各自獨立冷卻期,觸發時記入關係帳本 - ScheduleEmotionBaselineService:I-5 作息驅動情緒基線(社團剛結束的疲憊、假日偏高心情), 只在本輪沒有明確情緒訊號時介入,串接進 EmotionService.processInput 的新增選項 - OfflineEventService:I-6 離線事件生成,依「今天已發生的固定行程」挑選對應模板, 一天只生成一次,寫入低權重情節記憶 - DialogueService 整合以上機制:離線事件生成 → 情緒基線標記 → 累積親密度 → 作息可用度判定 → 不符合則嘗試破例 → 依可用度決定回覆內容 - 修正真實 bug:G-5 角色建立流程誤用 source=OFFLINE_GENERATED 標記背景故事, 與 I-6「今天是否已生成離線事件」的判斷共用同一標記,已改為 SOURCE_EXTRACTION - ChatView.tsx 補上 NO_REPLY/context 為 null 的防呆(作息可用度整合後的必要調整) - scripts/smoke/I.mjs:以獨立測試角色(避免污染種子角色的排程狀態)驗證全部六項 npm run restart && npm run smoke -- I 皆通過(I-V),A~H 群組冒煙測試無回歸。
163 lines
7.4 KiB
JavaScript
163 lines
7.4 KiB
JavaScript
import { prisma } from "@kokorone/db";
|
||
|
||
const API_PORT = process.env.PORT_API ?? "3001";
|
||
const CHARACTER_ID = "smoke-i-character";
|
||
const USER_ID = "seed-user-primary";
|
||
|
||
// 固定測試時間軸(皆為 UTC,對應 Asia/Taipei 當地時間,註解為當地時間):
|
||
const WEEKDAY_CLASS_TIME = "2026-08-13T02:00:00.000Z"; // 週四 10:00(上課,BUSY)
|
||
const WEEKDAY_FREE_TIME = "2026-08-13T14:00:00.000Z"; // 週四 22:00(自由時間,FREE)
|
||
const WEEKDAY_SLEEP_TIME = "2026-08-12T18:00:00.000Z"; // 週四 02:00(睡眠,SLEEP)
|
||
const WEEKDAY_WAKING_TIME = "2026-08-12T22:40:00.000Z"; // 週四 06:40(睡眠剛結束 10 分鐘)
|
||
|
||
async function post(path, body) {
|
||
const res = await fetch(`http://localhost:${API_PORT}${path}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body ?? {}),
|
||
});
|
||
if (!res.ok) {
|
||
throw new Error(`POST ${path} 回傳 ${res.status}:${await res.text()}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
async function get(path) {
|
||
const res = await fetch(`http://localhost:${API_PORT}${path}`);
|
||
if (!res.ok) {
|
||
throw new Error(`GET ${path} 回傳 ${res.status}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
export default async function smokeI() {
|
||
await prisma.character.deleteMany({ where: { id: CHARACTER_ID } });
|
||
await post("/personality/characters", {
|
||
id: CHARACTER_ID,
|
||
source: "ORIGINAL",
|
||
buildStatus: "BUILT",
|
||
formalName: "作息測試角色",
|
||
basicInfo: "作息測試角色,17 歲高中生",
|
||
backgroundStory: "普通的高中生活",
|
||
personalityArchetype: "元氣",
|
||
likesDislikes: "喜歡:運動;討厭:早起",
|
||
goalsObsessions: "測試用",
|
||
speechStyle: "第一人稱「我」",
|
||
initialRelationship: { userId: USER_ID, intimacy: 25, trust: 10 },
|
||
});
|
||
|
||
// I-1 作息表資料結構:可為角色寫入一份高中生作息表
|
||
await post(`/schedule/${CHARACTER_ID}/seed-default`, {});
|
||
const slotCount = await prisma.scheduleSlot.count({ where: { characterId: CHARACTER_ID } });
|
||
if (slotCount === 0) {
|
||
throw new Error("seed-default 未寫入任何作息時段");
|
||
}
|
||
|
||
// I-2/I-3 時段狀態 → 回應可用度 + 時間感知(星期幾/幾點)
|
||
const busyInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_CLASS_TIME)}`);
|
||
if (busyInfo.availability !== "DELAYED") {
|
||
throw new Error(`上課時段應對應 DELAYED,實際為 ${busyInfo.availability}`);
|
||
}
|
||
const freeInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_FREE_TIME)}`);
|
||
if (freeInfo.availability !== "NORMAL") {
|
||
throw new Error(`自由時間應對應 NORMAL,實際為 ${freeInfo.availability}`);
|
||
}
|
||
const sleepInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_SLEEP_TIME)}`);
|
||
if (sleepInfo.availability !== "NO_REPLY") {
|
||
throw new Error(`睡眠時段應對應 NO_REPLY,實際為 ${sleepInfo.availability}`);
|
||
}
|
||
const wakingInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_WAKING_TIME)}`);
|
||
if (!wakingInfo.isWaking) {
|
||
throw new Error("剛醒時間點應標記 isWaking");
|
||
}
|
||
|
||
// I-4 破例規則:睡眠時段一般不回應,且不應無條件破例
|
||
const sleepSessionA = "smoke-i-sleep-a-" + Date.now();
|
||
const noExceptionResult = await post(`/chat/${CHARACTER_ID}`, {
|
||
userId: USER_ID,
|
||
sessionId: sleepSessionA,
|
||
text: "在嗎",
|
||
now: WEEKDAY_SLEEP_TIME,
|
||
});
|
||
if (noExceptionResult.text !== "" || noExceptionResult.availability !== "NO_REPLY") {
|
||
throw new Error("條件不足時睡眠時段不應破例回應");
|
||
}
|
||
|
||
// 條件滿足(親密度高+使用者情緒低落)時應觸發「深夜還陪你聊」破例
|
||
await prisma.relationship.update({
|
||
where: { characterId_userId: { characterId: CHARACTER_ID, userId: USER_ID } },
|
||
data: { intimacy: 70 },
|
||
});
|
||
await prisma.emotionState.update({
|
||
where: { characterId: CHARACTER_ID },
|
||
data: { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date(WEEKDAY_SLEEP_TIME) },
|
||
});
|
||
const sleepSessionB = "smoke-i-sleep-b-" + Date.now();
|
||
const exceptionResult = await post(`/chat/${CHARACTER_ID}`, {
|
||
userId: USER_ID,
|
||
sessionId: sleepSessionB,
|
||
text: "我今天過得好難過,很失望",
|
||
now: WEEKDAY_SLEEP_TIME,
|
||
});
|
||
if (exceptionResult.exceptionType !== "LATE_NIGHT_CHAT" || !exceptionResult.text) {
|
||
throw new Error("條件滿足時應觸發「深夜還陪你聊」破例並正常生成回應");
|
||
}
|
||
|
||
// 冷卻期內同類破例不應再次觸發
|
||
const sleepSessionC = "smoke-i-sleep-c-" + Date.now();
|
||
const cooldownResult = await post(`/chat/${CHARACTER_ID}`, {
|
||
userId: USER_ID,
|
||
sessionId: sleepSessionC,
|
||
text: "我今天過得好難過,很失望",
|
||
now: WEEKDAY_SLEEP_TIME,
|
||
});
|
||
if (cooldownResult.exceptionType !== null || cooldownResult.availability !== "NO_REPLY") {
|
||
throw new Error("冷卻期內同類破例不應再次觸發");
|
||
}
|
||
|
||
// I-5 作息驅動情緒基線:同一輸入在不同時段得到不同情緒起點
|
||
const afterClub = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent("2026-08-13T10:15:00.000Z")}`);
|
||
if (afterClub.baseline?.tag !== "SAD") {
|
||
throw new Error("社團剛結束應有偏低落的情緒基線");
|
||
}
|
||
const weekendBaseline = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent("2026-08-15T06:00:00.000Z")}`);
|
||
if (weekendBaseline.baseline?.tag !== "JOY") {
|
||
throw new Error("放假日應有偏愉悅的情緒基線");
|
||
}
|
||
const normalBaseline = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent(WEEKDAY_FREE_TIME)}`);
|
||
if (normalBaseline.baseline !== null) {
|
||
throw new Error("一般時段不應有特殊情緒基線");
|
||
}
|
||
|
||
// I-6 離線事件生成:依作息生成的事件必須符合角色作息表(不可生成沒有的活動)
|
||
// 先清掉前面 I-4 測試(在 02:00 觸發對話)順帶生成的「今天」離線事件,確保本節從乾淨狀態開始。
|
||
await prisma.episodicMemory.deleteMany({ where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" } });
|
||
await prisma.emotionState.update({
|
||
where: { characterId: CHARACTER_ID },
|
||
data: { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date() },
|
||
});
|
||
const offlineEventTime = "2026-08-13T10:15:00.000Z"; // 週四 18:15,社團活動(16:00-18:00)剛結束
|
||
await post(`/schedule/${CHARACTER_ID}/offline-event`, { now: offlineEventTime });
|
||
const offlineMemories = await prisma.episodicMemory.findMany({
|
||
where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" },
|
||
});
|
||
if (offlineMemories.length !== 1) {
|
||
throw new Error(`應生成恰好一筆離線事件,實際為 ${offlineMemories.length} 筆`);
|
||
}
|
||
if (!offlineMemories[0].content.includes("社團")) {
|
||
throw new Error("離線事件內容應符合當天已發生的固定行程(社團活動)");
|
||
}
|
||
|
||
// 同一天再次觸發不應重複生成
|
||
await post(`/schedule/${CHARACTER_ID}/offline-event`, { now: "2026-08-13T11:00:00.000Z" });
|
||
const offlineMemoriesAgain = await prisma.episodicMemory.count({
|
||
where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" },
|
||
});
|
||
if (offlineMemoriesAgain !== 1) {
|
||
throw new Error("同一天不應重複生成離線事件");
|
||
}
|
||
|
||
// 清理本次測試建立的角色(cascade 會一併清掉作息表、破例紀錄、記憶等)
|
||
await prisma.character.deleteMany({ where: { id: CHARACTER_ID } });
|
||
}
|