feat: 完成 J 群組 — 委託子系統

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-08-13 14:35:16 +08:00
co-authored by Claude Sonnet 5
parent d82b834e47
commit 3503cc0be6
16 changed files with 1117 additions and 8 deletions
+256
View File
@@ -0,0 +1,256 @@
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 } });
}