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
+4
View File
@@ -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"
}
}
+2 -1
View File
@@ -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 {}
+2
View File
@@ -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)}`);
}
}
}
+10
View File
@@ -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,
}));
}
}
+82
View File
@@ -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 };
}
}
+41
View File
@@ -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;
}
}