- WorkingMemoryService:session 對話上下文緩衝,token 溢位時優先裁切最舊的低情緒段落 - JobQueue 抽象層(now/schedule(at)/every(cron))與 InProcessJobQueue 實作(node-cron), R-6 會換成 BullMQ 但呼叫端介面不變 - MemoryConsolidationService:睡眠固化,只有高情緒或重複提及的內容才寫入 EpisodicMemory - ForgettingSweepService:低情緒且長期未提取的記憶逐次降權、weight 過低後刪除, 已掛上每小時一次的 JobQueue.every 排程 - KeywordMemoryRetriever:關鍵字+時間近因+情緒權重排序,提取命中即更新提取次數/時間 - apps/api 新增 /memory/* 端點作為引擎驗證介面(H 群組會決定併入正式對話管線後的去留) - scripts/smoke/C.mjs:端到端驗證溢位裁切、只寫工作記憶、固化篩選、檢索排序、 提取即改寫、schedule(at) 準時觸發、遺忘衰減與刪除 npm run restart && npm run smoke -- C 皆通過(C-V),A/B 群組冒煙測試無回歸。
137 lines
5.4 KiB
JavaScript
137 lines
5.4 KiB
JavaScript
import { prisma } from "@kokorone/db";
|
||
|
||
const API_PORT = process.env.PORT_API ?? "3001";
|
||
const CHARACTER_ID = "seed-character-genki";
|
||
|
||
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}`);
|
||
}
|
||
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();
|
||
}
|
||
|
||
async function countLongTermMemories() {
|
||
const [episodic, semantic, procedural] = await Promise.all([
|
||
prisma.episodicMemory.count(),
|
||
prisma.semanticMemory.count(),
|
||
prisma.proceduralRule.count(),
|
||
]);
|
||
return episodic + semantic + procedural;
|
||
}
|
||
|
||
export default async function smokeC() {
|
||
const sessionId = `smoke-c-${Date.now()}`;
|
||
const highEmotionContent = `我剛剛收到夢寐以求的錄取通知,超級感動![${sessionId}]`;
|
||
|
||
const countBeforeAppend = await countLongTermMemories();
|
||
|
||
// C-1 工作記憶溢位裁切:附加夠多低情緒內容觸發裁切,驗證高情緒段落被保留
|
||
const filler = "今天天氣普通普通的沒什麼特別事情發生。";
|
||
for (let i = 0; i < 5; i++) {
|
||
await post(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`, {
|
||
role: "user",
|
||
content: `${filler.repeat(6)}${i}`,
|
||
emotionTag: "CALM",
|
||
emotionIntensity: 0.05,
|
||
});
|
||
}
|
||
await post(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`, {
|
||
role: "user",
|
||
content: highEmotionContent,
|
||
emotionTag: "JOY",
|
||
emotionIntensity: 0.95,
|
||
});
|
||
|
||
const { context: afterOverflow } = await get(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`);
|
||
if (!afterOverflow.some((m) => m.content === highEmotionContent)) {
|
||
throw new Error("工作記憶溢位裁切錯誤地清掉了高情緒段落");
|
||
}
|
||
if (afterOverflow.length >= 6) {
|
||
throw new Error("工作記憶溢位裁切未生效,段落數未減少");
|
||
}
|
||
|
||
// C-2 對話期只寫工作記憶:附加訊息期間長期記憶表筆數不應變動
|
||
const countAfterAppend = await countLongTermMemories();
|
||
if (countAfterAppend !== countBeforeAppend) {
|
||
throw new Error("對話期間長期記憶表筆數發生變化,違反「對話中不即時寫長期記憶」");
|
||
}
|
||
|
||
// C-4 睡眠固化:只有高情緒事件進 EpisodicMemory,瑣事不進
|
||
await post(`/memory/${CHARACTER_ID}/sessions/${sessionId}/consolidate`);
|
||
const { context: afterConsolidate } = await get(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`);
|
||
if (afterConsolidate.length !== 0) {
|
||
throw new Error("固化後工作記憶未清空");
|
||
}
|
||
|
||
const countAfterConsolidate = await countLongTermMemories();
|
||
if (countAfterConsolidate !== countBeforeAppend + 1) {
|
||
throw new Error(
|
||
`固化後長期記憶筆數變化不符預期(預期 +1,實際 ${countAfterConsolidate - countBeforeAppend})`,
|
||
);
|
||
}
|
||
|
||
// C-6 記憶檢索器:關鍵字命中、瑣事未污染結果
|
||
const { results: retrieved } = await get(
|
||
`/memory/${CHARACTER_ID}/retrieve?query=${encodeURIComponent("錄取通知")}`,
|
||
);
|
||
const consolidatedMemory = retrieved.find((m) => m.content === highEmotionContent);
|
||
if (!consolidatedMemory) {
|
||
throw new Error("高情緒事件未寫入 EpisodicMemory 或檢索不到");
|
||
}
|
||
if (retrieved.some((m) => m.content.startsWith(filler))) {
|
||
throw new Error("瑣事錯誤地被寫入 EpisodicMemory");
|
||
}
|
||
|
||
// C-5 提取即改寫:檢索命中後提取次數遞增
|
||
const { results: retrievedAgain } = await get(
|
||
`/memory/${CHARACTER_ID}/retrieve?query=${encodeURIComponent("錄取通知")}`,
|
||
);
|
||
const secondHit = retrievedAgain.find((m) => m.id === consolidatedMemory.id);
|
||
if (!secondHit || secondHit.retrievalCount <= consolidatedMemory.retrievalCount) {
|
||
throw new Error("重複檢索未持續遞增提取次數");
|
||
}
|
||
|
||
// C-5 遺忘:模擬時間推進,高情緒記憶不應被衰減
|
||
const twoDaysLater = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString();
|
||
await post("/memory/forgetting-sweep", { now: twoDaysLater });
|
||
const { results: afterSweep } = await get(
|
||
`/memory/${CHARACTER_ID}/retrieve?query=${encodeURIComponent("錄取通知")}`,
|
||
);
|
||
const highEmotionAfterSweep = afterSweep.find((m) => m.id === consolidatedMemory.id);
|
||
if (!highEmotionAfterSweep || highEmotionAfterSweep.weight < secondHit.weight) {
|
||
throw new Error("遺忘程序錯誤地影響了高情緒記憶");
|
||
}
|
||
|
||
// 清理本次測試寫入的長期記憶,避免重複執行時累積髒資料
|
||
await prisma.episodicMemory.delete({ where: { id: consolidatedMemory.id } });
|
||
|
||
// C-3 排程抽象層:schedule(at) 排入的工作在時間到之前不觸發、時間到之後準時觸發
|
||
const jobId = `smoke-c-schedule-${Date.now()}`;
|
||
await post("/memory/test-scheduled-job", { id: jobId, delaySeconds: 2 });
|
||
|
||
const { fired: firedTooEarly } = await get(`/memory/test-scheduled-job/${jobId}`);
|
||
if (firedTooEarly) {
|
||
throw new Error("schedule(at) 排入的工作在時間到之前就觸發了");
|
||
}
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||
|
||
const { fired: firedOnTime } = await get(`/memory/test-scheduled-job/${jobId}`);
|
||
if (!firedOnTime) {
|
||
throw new Error("schedule(at) 排入的工作逾時未觸發");
|
||
}
|
||
}
|