Files
Kokorone/scripts/smoke/J.mjs
T

260 lines
10 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 = "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("pnpm", ["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" } });
}