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 群組冒煙測試無回歸。
This commit is contained in:
Jeffery
2026-08-13 09:54:29 +08:00
parent f44200f543
commit e6f25008d1
14 changed files with 580 additions and 8 deletions
@@ -0,0 +1,51 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { WorkingMemoryService } from "./working-memory.service.js";
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
function normalize(content: string): string {
return content.trim().toLowerCase();
}
// C-4 睡眠固化:對話中只累積在工作記憶,session 結束時才批次萃取寫入長期記憶。
@Injectable()
export class MemoryConsolidationService {
constructor(
private readonly prisma: PrismaService,
private readonly workingMemory: WorkingMemoryService,
) {}
async consolidate(characterId: string, sessionId: string): Promise<void> {
const entries = this.workingMemory.getContext(sessionId);
const contentCounts = new Map<string, number>();
for (const entry of entries) {
const key = normalize(entry.content);
contentCounts.set(key, (contentCounts.get(key) ?? 0) + 1);
}
for (const entry of entries) {
const emotionIntensity = entry.emotionIntensity ?? 0;
const isHighEmotion = emotionIntensity >= HIGH_EMOTION_THRESHOLD;
const isRepeated = (contentCounts.get(normalize(entry.content)) ?? 0) > 1;
if (!isHighEmotion && !isRepeated) {
continue;
}
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content: entry.content,
occurredAt: entry.timestamp,
emotionTag: entry.emotionTag ?? "CALM",
emotionIntensity,
source: "INTERACTION",
weight: isHighEmotion ? 1 + emotionIntensity : 0.5,
},
});
}
this.workingMemory.clear(sessionId);
}
}