Task 資料表與自然語句解析(單次提醒)、依原型的接案/觸發/逾期追擊演出、 委託與關係帳本/情緒的回饋迴路(含默契旗標)、主動關照(待辦偵測+作息交叉比對, 稀有度節流不重複台詞)、排程器可靠性(服務重啟後重新掃描 PENDING 任務掛回 JobQueue,逾期立即補觸發)。J.mjs 以 spawnSync 實際重啟服務驗證 J-7。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
257 lines
9.9 KiB
JavaScript
257 lines
9.9 KiB
JavaScript
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 } });
|
||
}
|