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,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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user