import { prisma } from "@kokorone/db"; const API_PORT = process.env.PORT_API ?? "3001"; const CHARACTER_ID = "smoke-j-character"; const USER_ID = "seed-user-primary"; 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(); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } export default async function smokeJ() { await prisma.character.deleteMany({ where: { id: CHARACTER_ID } }); await post("/personality/characters", { id: CHARACTER_ID, source: "ORIGINAL", buildStatus: "BUILT", formalName: "委託測試角色", basicInfo: "委託測試角色", backgroundStory: "測試用背景", personalityArchetype: "元氣", likesDislikes: "測試", goalsObsessions: "測試", speechStyle: "第一人稱「我」", initialRelationship: { userId: USER_ID, intimacy: 30, trust: 10 }, }); const PARSE_NOW = "2026-08-13T00:00:00.000Z"; // 週四 08:00 Taipei // J-1 自然語句解析:時間片語+內容都要正確拆解出來。 const parsedMeeting = await post(`/task/${CHARACTER_ID}/parse`, { userId: USER_ID, text: "三點提醒我開會", now: PARSE_NOW, }); if (!parsedMeeting.created || parsedMeeting.task.content !== "開會") { throw new Error(`「三點提醒我開會」應解析出內容「開會」,實際為 ${JSON.stringify(parsedMeeting)}`); } if (parsedMeeting.task.triggerAt !== "2026-08-13T07:00:00.000Z") { throw new Error(`「三點」在 08:00 之後應解析為當天 15:00(UTC 07:00),實際為 ${parsedMeeting.task.triggerAt}`); } const parsedTrash = await post(`/task/${CHARACTER_ID}/parse`, { userId: USER_ID, text: "晚上八點半提醒我倒垃圾", now: PARSE_NOW, }); if (parsedTrash.task.content !== "倒垃圾" || parsedTrash.task.triggerAt !== "2026-08-13T12:30:00.000Z") { throw new Error(`「晚上八點半」應解析為當天 20:30(UTC 12:30),實際為 ${JSON.stringify(parsedTrash.task)}`); } const unparsed = await post(`/task/${CHARACTER_ID}/parse`, { userId: USER_ID, text: "今天天氣真好", now: PARSE_NOW, }); if (unparsed.created !== false) { throw new Error("不含委託語句的輸入不應被解析為任務"); } // J-2 接案演出:無論表面台詞如何,引擎層都一定實際建立了任務(上面兩筆已驗證),且有對應原型的演出台詞。 if (!parsedMeeting.performanceLine) { throw new Error("接案應附上演出台詞"); } // J-3 觸發演出:語氣依時段調整,且能帶出相關記憶回顧。 await prisma.episodicMemory.create({ data: { characterId: CHARACTER_ID, content: "使用者說要看書準備考試", occurredAt: new Date(), emotionTag: "CALM", emotionIntensity: 0.3, source: "INTERACTION", relatedUserId: USER_ID, }, }); const readingTask = await post(`/task/${CHARACTER_ID}`, { userId: USER_ID, type: "ONE_TIME", content: "看書", rawText: "十點提醒我看書", }); const lateNightFire = await post(`/task/${readingTask.task.id}/fire-now`, { now: "2026-08-12T18:00:00.000Z" }); if (!lateNightFire.message?.startsWith("(小聲)")) { throw new Error(`深夜觸發語氣應該放輕聲量,實際為 ${lateNightFire.message}`); } const readingTask2 = await post(`/task/${CHARACTER_ID}`, { userId: USER_ID, type: "ONE_TIME", content: "看書", rawText: "十點提醒我看書", }); const daytimeFire = await post(`/task/${readingTask2.task.id}/fire-now`, { now: "2026-08-13T05:00:00.000Z" }); if (!daytimeFire.message.includes("使用者說要看書準備考試")) { throw new Error(`白天觸發應帶出相關記憶回顧,實際為 ${daytimeFire.message}`); } // J-4 逾期追擊:觸發後太快檢查不應追擊;超過寬限期後應逐次追擊,累積到門檻應觸發「被忽視」。 const dishesTask = await post(`/task/${CHARACTER_ID}`, { userId: USER_ID, type: "ONE_TIME", content: "洗碗", rawText: "提醒我洗碗", }); await post(`/task/${dishesTask.task.id}/fire-now`, { now: "2026-08-13T05:00:00.000Z" }); const tooEarly = await post(`/task/${dishesTask.task.id}/check-overdue`, { now: "2026-08-13T05:05:00.000Z" }); if (tooEarly.message !== null) { throw new Error("寬限期內不應該追擊"); } const nudge1 = await post(`/task/${dishesTask.task.id}/check-overdue`, { now: "2026-08-13T05:15:00.000Z" }); const nudge2 = await post(`/task/${dishesTask.task.id}/check-overdue`, { now: "2026-08-13T05:25:00.000Z" }); const nudge3 = await post(`/task/${dishesTask.task.id}/check-overdue`, { now: "2026-08-13T05:35:00.000Z" }); if (!nudge1.message || !nudge2.message || !nudge3.message || nudge1.message === nudge2.message) { throw new Error("逾期追擊應該逐次產生(且不完全重複的)訊息"); } const relationshipAfterIgnore = await prisma.relationship.findUnique({ where: { characterId_userId: { characterId: CHARACTER_ID, userId: USER_ID } }, }); const ignoredLedger = await prisma.sentimentLedgerEntry.findFirst({ where: { relationshipId: relationshipAfterIgnore.id, event: "TASK_IGNORED" }, }); if (!ignoredLedger) { throw new Error("累積追擊達門檻仍無回應時,應記錄一筆 TASK_IGNORED 負向事件"); } // J-5 委託回饋迴路:完成/失敗會誠實寫入關係帳本;重複同類委託形成默契旗標。 const completeTarget = await post(`/task/${CHARACTER_ID}`, { userId: USER_ID, type: "ONE_TIME", content: "測試完成", rawText: "提醒我測試完成", }); await post(`/task/${completeTarget.task.id}/complete`, {}); const completedTask = await get(`/task/${completeTarget.task.id}`); if (completedTask.status !== "COMPLETED" || !completedTask.completedAt) { throw new Error("完成後任務狀態應為 COMPLETED 且有 completedAt"); } const completedLedger = await prisma.sentimentLedgerEntry.findFirst({ where: { relationshipId: relationshipAfterIgnore.id, event: "TASK_COMPLETED" }, }); if (!completedLedger || completedLedger.weight <= 0) { throw new Error("完成任務應記錄一筆正向的 TASK_COMPLETED 事件"); } const failTarget = await post(`/task/${CHARACTER_ID}`, { userId: USER_ID, type: "ONE_TIME", content: "測試失敗", rawText: "提醒我測試失敗", }); await post(`/task/${failTarget.task.id}/fail`, {}); const failedTask = await get(`/task/${failTarget.task.id}`); if (failedTask.status !== "FAILED") { throw new Error("失敗後任務狀態應為 FAILED"); } const rapport1 = await post(`/task/${CHARACTER_ID}`, { userId: USER_ID, type: "TODO_TRACKING", content: "倒垃圾", rawText: "追蹤倒垃圾", }); const rapport2 = await post(`/task/${CHARACTER_ID}`, { userId: USER_ID, type: "TODO_TRACKING", content: "倒垃圾", rawText: "追蹤倒垃圾", }); if (rapport1.rapport !== false || rapport2.rapport !== true) { throw new Error("同類委託第二次提出時應形成默契旗標(rapport=true)"); } // J-6 主動關照:從記憶偵測待辦並主動關心,且有稀有度節流(冷卻期內不重複觸發)。 await prisma.episodicMemory.create({ data: { characterId: CHARACTER_ID, content: "使用者說明天要交報告", occurredAt: new Date(), emotionTag: "CALM", emotionIntensity: 0.3, source: "INTERACTION", relatedUserId: USER_ID, }, }); const careFirst = await post(`/task/proactive-care/${CHARACTER_ID}`, { userId: USER_ID }); if (!careFirst.message?.includes("要交報告")) { throw new Error(`主動關照應引用偵測到的待辦記憶,實際為 ${careFirst.message}`); } const careSecond = await post(`/task/proactive-care/${CHARACTER_ID}`, { userId: USER_ID }); if (careSecond.message !== null) { throw new Error("冷卻期內不應該再次觸發主動關照"); } // J-7 排程器可靠性:建立提醒後即使服務重啟(記憶體計時器全部消失),時間到仍準時觸發。 // 必須使用真實時間(而非模擬 now),因為 InProcessJobQueue 的排程是依真實時鐘計算延遲。 const realTriggerAt = new Date(Date.now() + 8000); const reliabilityTask = await post(`/task/${CHARACTER_ID}`, { userId: USER_ID, type: "ONE_TIME", content: "重啟可靠性測試", rawText: "重啟可靠性測試", triggerAt: realTriggerAt.toISOString(), }); const { spawnSync } = await import("node:child_process"); const restartResult = spawnSync("npm", ["run", "restart"], { cwd: process.cwd(), stdio: "pipe" }); if (restartResult.status !== 0) { throw new Error(`重啟服務失敗:${restartResult.stderr?.toString()}`); } const deadline = Date.now() + 20_000; let fired = null; while (Date.now() < deadline) { // 重啟瞬間舊連線可能被強制關閉(keep-alive socket 指向已終止的舊行程),視為尚未就緒,稍後重試。 let current; try { current = await get(`/task/${reliabilityTask.task.id}`); } catch { await sleep(500); continue; } if (current.status === "TRIGGERED") { fired = current; break; } await sleep(500); } if (!fired) { throw new Error("重啟服務後,任務應在原訂時間準時觸發(狀態應變為 TRIGGERED)"); } // 清理本次測試建立的角色(cascade 會一併清掉任務、主動關照紀錄、記憶等)。 await prisma.character.deleteMany({ where: { id: CHARACTER_ID } }); // Q-3:任務觸發會嘗試推播給使用者,PushNotificationLog 不隨 Character cascade 清除 // (它只綁 userId,不綁 characterId),這裡額外清掉本次測試產生的推播記錄,避免無限累積。 await prisma.pushNotificationLog.deleteMany({ where: { userId: USER_ID, title: "心音 Kokorone" } }); }