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:
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { EmotionTag } from "@kokorone/shared";
|
||||
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
|
||||
|
||||
export interface WorkingMemoryEntry {
|
||||
role: "user" | "character";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
emotionTag?: EmotionTag;
|
||||
emotionIntensity?: number; // 0~1,未標記情緒的訊息(如快速通道問候)可省略
|
||||
}
|
||||
|
||||
const DEFAULT_TOKEN_LIMIT = 200;
|
||||
|
||||
// 粗略估算:Mock 階段不需要真實 tokenizer,先以字元數/2 近似。
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 2);
|
||||
}
|
||||
|
||||
function isHighEmotion(entry: WorkingMemoryEntry): boolean {
|
||||
return (entry.emotionIntensity ?? 0) >= HIGH_EMOTION_THRESHOLD;
|
||||
}
|
||||
|
||||
// C-1 工作記憶:session 對話上下文緩衝,只存在於行程記憶體中(如同海馬迴暫存),
|
||||
// 不落地到任何長期記憶表——長期寫入只能由 C-4 睡眠固化觸發。
|
||||
@Injectable()
|
||||
export class WorkingMemoryService {
|
||||
private readonly sessions = new Map<string, WorkingMemoryEntry[]>();
|
||||
private readonly tokenLimit = DEFAULT_TOKEN_LIMIT;
|
||||
|
||||
append(sessionId: string, entry: WorkingMemoryEntry): void {
|
||||
const entries = this.sessions.get(sessionId) ?? [];
|
||||
entries.push(entry);
|
||||
this.sessions.set(sessionId, this.evictOverflow(entries));
|
||||
}
|
||||
|
||||
getContext(sessionId: string): WorkingMemoryEntry[] {
|
||||
return [...(this.sessions.get(sessionId) ?? [])];
|
||||
}
|
||||
|
||||
clear(sessionId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
private evictOverflow(entries: WorkingMemoryEntry[]): WorkingMemoryEntry[] {
|
||||
let result = entries;
|
||||
let totalTokens = result.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
||||
|
||||
while (totalTokens > this.tokenLimit && result.length > 0) {
|
||||
// 最舊的低情緒段落先被裁掉;若全部都是高情緒,最後才犧牲最舊的一則以確保不超出上限。
|
||||
let evictIndex = result.findIndex((e) => !isHighEmotion(e));
|
||||
if (evictIndex === -1) {
|
||||
evictIndex = 0;
|
||||
}
|
||||
const [evicted] = result.splice(evictIndex, 1);
|
||||
totalTokens -= estimateTokens(evicted.content);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user