Files
Jeffery e6f25008d1 feat: 完成 C 群組 — 記憶子系統
- 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 群組冒煙測試無回歸。
2026-08-13 09:54:29 +08:00

137 lines
5.4 KiB
JavaScript
Raw Permalink 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 = "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) 排入的工作逾時未觸發");
}
}