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:
@@ -16,7 +16,11 @@
|
||||
"@nestjs/core": "^11.1.29",
|
||||
"@nestjs/platform-express": "^11.1.29",
|
||||
"dotenv": "^17.4.2",
|
||||
"node-cron": "^4.6.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-cron": "^3.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health/health.controller.js";
|
||||
import { CharactersModule } from "./characters/characters.module.js";
|
||||
import { MemoryModule } from "./memory/memory.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [CharactersModule],
|
||||
imports: [CharactersModule, MemoryModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// 情緒強度視為「高情緒」的門檻(0~1),C-1 溢位裁切保護、C-4 固化判斷、C-5 遺忘判斷共用同一門檻。
|
||||
export const HIGH_EMOTION_THRESHOLD = 0.6;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
|
||||
|
||||
const STALE_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 小時未被提取視為「長期未被提取」
|
||||
const DECAY_FACTOR = 0.5;
|
||||
const MIN_WEIGHT = 0.05;
|
||||
|
||||
// C-5 遺忘(自然衰減):情緒強度低且長期未被提取的記憶逐次降權,權重低於門檻後刪除。
|
||||
@Injectable()
|
||||
export class ForgettingSweepService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async run(now: Date = new Date()): Promise<{ decayed: number; deleted: number }> {
|
||||
const candidates = await this.prisma.client.episodicMemory.findMany({
|
||||
where: { emotionIntensity: { lt: HIGH_EMOTION_THRESHOLD } },
|
||||
});
|
||||
|
||||
let decayed = 0;
|
||||
let deleted = 0;
|
||||
|
||||
for (const memory of candidates) {
|
||||
const lastActivity = memory.lastRetrievedAt ?? memory.createdAt;
|
||||
const staleFor = now.getTime() - lastActivity.getTime();
|
||||
if (staleFor < STALE_WINDOW_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextWeight = memory.weight * DECAY_FACTOR;
|
||||
if (nextWeight < MIN_WEIGHT) {
|
||||
await this.prisma.client.episodicMemory.delete({ where: { id: memory.id } });
|
||||
deleted += 1;
|
||||
} else {
|
||||
await this.prisma.client.episodicMemory.update({
|
||||
where: { id: memory.id },
|
||||
data: { weight: nextWeight },
|
||||
});
|
||||
decayed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { decayed, deleted };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable, OnModuleDestroy } from "@nestjs/common";
|
||||
import cron from "node-cron";
|
||||
import { log } from "@kokorone/shared";
|
||||
import type { JobHandler, JobQueue } from "./job-queue.js";
|
||||
|
||||
@Injectable()
|
||||
export class InProcessJobQueue implements JobQueue, OnModuleDestroy {
|
||||
private readonly timers = new Set<NodeJS.Timeout>();
|
||||
private readonly cronTasks: ReturnType<typeof cron.schedule>[] = [];
|
||||
|
||||
now(name: string, handler: JobHandler): void {
|
||||
setImmediate(() => this.run(name, handler));
|
||||
}
|
||||
|
||||
schedule(at: Date, name: string, handler: JobHandler): void {
|
||||
const delayMs = Math.max(0, at.getTime() - Date.now());
|
||||
const timer = setTimeout(() => {
|
||||
this.timers.delete(timer);
|
||||
void this.run(name, handler);
|
||||
}, delayMs);
|
||||
this.timers.add(timer);
|
||||
}
|
||||
|
||||
every(cronExpression: string, name: string, handler: JobHandler): void {
|
||||
const task = cron.schedule(cronExpression, () => this.run(name, handler));
|
||||
this.cronTasks.push(task);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
for (const timer of this.timers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
for (const task of this.cronTasks) {
|
||||
task.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async run(name: string, handler: JobHandler): Promise<void> {
|
||||
try {
|
||||
await handler();
|
||||
} catch (err) {
|
||||
log("排程", "ERR", `工作 ${name} 執行失敗:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type JobHandler = () => Promise<void> | void;
|
||||
|
||||
// C-3 排程抽象層:R-6 會換成 BullMQ 實作,呼叫端只依賴這個介面。
|
||||
export interface JobQueue {
|
||||
now(name: string, handler: JobHandler): void;
|
||||
schedule(at: Date, name: string, handler: JobHandler): void;
|
||||
every(cronExpression: string, name: string, handler: JobHandler): void;
|
||||
}
|
||||
|
||||
export const JOB_QUEUE = Symbol("JOB_QUEUE");
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import type { EmotionTag } from "@kokorone/shared";
|
||||
import { WorkingMemoryService } from "./working-memory.service.js";
|
||||
import { MemoryConsolidationService } from "./memory-consolidation.service.js";
|
||||
import { ForgettingSweepService } from "./forgetting-sweep.service.js";
|
||||
import { KeywordMemoryRetriever } from "./memory-retriever.service.js";
|
||||
import { InProcessJobQueue } from "./in-process-job-queue.service.js";
|
||||
|
||||
interface AppendMessageBody {
|
||||
role: "user" | "character";
|
||||
content: string;
|
||||
emotionTag?: EmotionTag;
|
||||
emotionIntensity?: number;
|
||||
}
|
||||
|
||||
@Controller("memory")
|
||||
export class MemoryController {
|
||||
// C-3 排程抽象層驗證用:記錄測試性延遲工作是否已觸發,僅供 smoke test 檢查 schedule(at) 準時性。
|
||||
private readonly scheduledJobFired = new Map<string, boolean>();
|
||||
|
||||
constructor(
|
||||
private readonly workingMemory: WorkingMemoryService,
|
||||
private readonly consolidation: MemoryConsolidationService,
|
||||
private readonly forgettingSweep: ForgettingSweepService,
|
||||
private readonly retriever: KeywordMemoryRetriever,
|
||||
private readonly jobQueue: InProcessJobQueue,
|
||||
) {}
|
||||
|
||||
@Post(":characterId/sessions/:sessionId/messages")
|
||||
appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
|
||||
this.workingMemory.append(sessionId, {
|
||||
role: body.role,
|
||||
content: body.content,
|
||||
timestamp: new Date(),
|
||||
emotionTag: body.emotionTag,
|
||||
emotionIntensity: body.emotionIntensity,
|
||||
});
|
||||
return { context: this.workingMemory.getContext(sessionId) };
|
||||
}
|
||||
|
||||
@Get(":characterId/sessions/:sessionId/messages")
|
||||
getContext(@Param("sessionId") sessionId: string) {
|
||||
return { context: this.workingMemory.getContext(sessionId) };
|
||||
}
|
||||
|
||||
@Post(":characterId/sessions/:sessionId/consolidate")
|
||||
async consolidate(@Param("characterId") characterId: string, @Param("sessionId") sessionId: string) {
|
||||
await this.consolidation.consolidate(characterId, sessionId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get(":characterId/retrieve")
|
||||
async retrieve(
|
||||
@Param("characterId") characterId: string,
|
||||
@Query("query") query: string,
|
||||
@Query("limit") limit?: string,
|
||||
) {
|
||||
const results = await this.retriever.retrieve(characterId, query ?? "", limit ? Number(limit) : undefined);
|
||||
return { results };
|
||||
}
|
||||
|
||||
@Post("forgetting-sweep")
|
||||
async runForgettingSweep(@Body() body: { now?: string }) {
|
||||
const now = body?.now ? new Date(body.now) : new Date();
|
||||
return this.forgettingSweep.run(now);
|
||||
}
|
||||
|
||||
@Post("test-scheduled-job")
|
||||
scheduleTestJob(@Body() body: { id: string; delaySeconds: number }) {
|
||||
this.scheduledJobFired.set(body.id, false);
|
||||
const at = new Date(Date.now() + body.delaySeconds * 1000);
|
||||
this.jobQueue.schedule(at, `test-job-${body.id}`, () => {
|
||||
this.scheduledJobFired.set(body.id, true);
|
||||
});
|
||||
return { scheduled: true };
|
||||
}
|
||||
|
||||
@Get("test-scheduled-job/:id")
|
||||
checkTestJob(@Param("id") id: string) {
|
||||
return { fired: this.scheduledJobFired.get(id) ?? false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Module, OnModuleInit } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { WorkingMemoryService } from "./working-memory.service.js";
|
||||
import { MemoryConsolidationService } from "./memory-consolidation.service.js";
|
||||
import { ForgettingSweepService } from "./forgetting-sweep.service.js";
|
||||
import { KeywordMemoryRetriever } from "./memory-retriever.service.js";
|
||||
import { InProcessJobQueue } from "./in-process-job-queue.service.js";
|
||||
import { MemoryController } from "./memory.controller.js";
|
||||
import { JOB_QUEUE } from "./job-queue.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [MemoryController],
|
||||
providers: [
|
||||
WorkingMemoryService,
|
||||
MemoryConsolidationService,
|
||||
ForgettingSweepService,
|
||||
KeywordMemoryRetriever,
|
||||
InProcessJobQueue,
|
||||
{ provide: JOB_QUEUE, useExisting: InProcessJobQueue },
|
||||
],
|
||||
exports: [
|
||||
WorkingMemoryService,
|
||||
MemoryConsolidationService,
|
||||
ForgettingSweepService,
|
||||
KeywordMemoryRetriever,
|
||||
JOB_QUEUE,
|
||||
],
|
||||
})
|
||||
export class MemoryModule implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly jobQueue: InProcessJobQueue,
|
||||
private readonly forgettingSweep: ForgettingSweepService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.jobQueue.every("0 * * * *", "forgetting-sweep", async () => {
|
||||
await this.forgettingSweep.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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