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,69 @@
import { Injectable } from "@nestjs/common";
import type { EpisodicMemory } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js";
export interface MemoryRetriever {
retrieve(characterId: string, query: string, limit?: number): Promise<EpisodicMemory[]>;
}
const WEIGHTS = {
keyword: 0.5,
recency: 0.3,
emotion: 0.2,
};
const DEFAULT_LIMIT = 5;
function keywordRelevance(content: string, keywords: string[]): number {
if (keywords.length === 0) {
return 0;
}
const hits = keywords.filter((keyword) => content.includes(keyword)).length;
return hits / keywords.length;
}
// C-6 記憶檢索器(關鍵字版本):排序權重=關鍵字相關度+時間近因+情緒權重。
// R-2 會加上 pgvector 語意檢索實作,取代這裡的關鍵字比對。
@Injectable()
export class KeywordMemoryRetriever implements MemoryRetriever {
constructor(private readonly prisma: PrismaService) {}
async retrieve(characterId: string, query: string, limit = DEFAULT_LIMIT): Promise<EpisodicMemory[]> {
const rows = await this.prisma.client.episodicMemory.findMany({ where: { characterId } });
const now = Date.now();
const keywords = query.split(/\s+/).filter(Boolean);
const scored = rows.map((row) => {
const keywordScore = keywordRelevance(row.content, keywords);
const daysSince = (now - row.occurredAt.getTime()) / (1000 * 60 * 60 * 24);
const recencyScore = 1 / (1 + Math.max(0, daysSince));
const emotionScore = row.emotionIntensity;
const score =
keywordScore * WEIGHTS.keyword + recencyScore * WEIGHTS.recency + emotionScore * WEIGHTS.emotion;
return { row, score };
});
scored.sort((a, b) => b.score - a.score);
const top = scored.slice(0, limit).map((s) => s.row);
if (top.length > 0) {
await this.prisma.client.episodicMemory.updateMany({
where: { id: { in: top.map((row) => row.id) } },
data: { lastRetrievedAt: new Date(now), retrievalCount: { increment: 1 } },
});
}
return top.map((row) => ({
id: row.id,
characterId: row.characterId,
content: row.content,
occurredAt: row.occurredAt.toISOString(),
emotionTag: row.emotionTag,
emotionIntensity: row.emotionIntensity,
retrievalCount: row.retrievalCount + 1,
lastRetrievedAt: new Date(now).toISOString(),
source: row.source,
weight: row.weight,
}));
}
}