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;
|
||||
}
|
||||
}
|
||||
Generated
+20
@@ -31,8 +31,12 @@
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"apps/web": {
|
||||
@@ -2470,6 +2474,13 @@
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-cron": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
||||
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
@@ -7840,6 +7851,15 @@
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz",
|
||||
"integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/node-exports-info": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { prisma } from "@kokorone/db";
|
||||
|
||||
const API_PORT = process.env.PORT_API ?? "3001";
|
||||
const CHARACTER_ID = "seed-character-genki";
|
||||
|
||||
async function post(path, body) {
|
||||
const res = await fetch(`http://localhost:${API_PORT}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`POST ${path} 回傳 ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function get(path) {
|
||||
const res = await fetch(`http://localhost:${API_PORT}${path}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET ${path} 回傳 ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function countLongTermMemories() {
|
||||
const [episodic, semantic, procedural] = await Promise.all([
|
||||
prisma.episodicMemory.count(),
|
||||
prisma.semanticMemory.count(),
|
||||
prisma.proceduralRule.count(),
|
||||
]);
|
||||
return episodic + semantic + procedural;
|
||||
}
|
||||
|
||||
export default async function smokeC() {
|
||||
const sessionId = `smoke-c-${Date.now()}`;
|
||||
const highEmotionContent = `我剛剛收到夢寐以求的錄取通知,超級感動![${sessionId}]`;
|
||||
|
||||
const countBeforeAppend = await countLongTermMemories();
|
||||
|
||||
// C-1 工作記憶溢位裁切:附加夠多低情緒內容觸發裁切,驗證高情緒段落被保留
|
||||
const filler = "今天天氣普通普通的沒什麼特別事情發生。";
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await post(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`, {
|
||||
role: "user",
|
||||
content: `${filler.repeat(6)}${i}`,
|
||||
emotionTag: "CALM",
|
||||
emotionIntensity: 0.05,
|
||||
});
|
||||
}
|
||||
await post(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`, {
|
||||
role: "user",
|
||||
content: highEmotionContent,
|
||||
emotionTag: "JOY",
|
||||
emotionIntensity: 0.95,
|
||||
});
|
||||
|
||||
const { context: afterOverflow } = await get(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`);
|
||||
if (!afterOverflow.some((m) => m.content === highEmotionContent)) {
|
||||
throw new Error("工作記憶溢位裁切錯誤地清掉了高情緒段落");
|
||||
}
|
||||
if (afterOverflow.length >= 6) {
|
||||
throw new Error("工作記憶溢位裁切未生效,段落數未減少");
|
||||
}
|
||||
|
||||
// C-2 對話期只寫工作記憶:附加訊息期間長期記憶表筆數不應變動
|
||||
const countAfterAppend = await countLongTermMemories();
|
||||
if (countAfterAppend !== countBeforeAppend) {
|
||||
throw new Error("對話期間長期記憶表筆數發生變化,違反「對話中不即時寫長期記憶」");
|
||||
}
|
||||
|
||||
// C-4 睡眠固化:只有高情緒事件進 EpisodicMemory,瑣事不進
|
||||
await post(`/memory/${CHARACTER_ID}/sessions/${sessionId}/consolidate`);
|
||||
const { context: afterConsolidate } = await get(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`);
|
||||
if (afterConsolidate.length !== 0) {
|
||||
throw new Error("固化後工作記憶未清空");
|
||||
}
|
||||
|
||||
const countAfterConsolidate = await countLongTermMemories();
|
||||
if (countAfterConsolidate !== countBeforeAppend + 1) {
|
||||
throw new Error(
|
||||
`固化後長期記憶筆數變化不符預期(預期 +1,實際 ${countAfterConsolidate - countBeforeAppend})`,
|
||||
);
|
||||
}
|
||||
|
||||
// C-6 記憶檢索器:關鍵字命中、瑣事未污染結果
|
||||
const { results: retrieved } = await get(
|
||||
`/memory/${CHARACTER_ID}/retrieve?query=${encodeURIComponent("錄取通知")}`,
|
||||
);
|
||||
const consolidatedMemory = retrieved.find((m) => m.content === highEmotionContent);
|
||||
if (!consolidatedMemory) {
|
||||
throw new Error("高情緒事件未寫入 EpisodicMemory 或檢索不到");
|
||||
}
|
||||
if (retrieved.some((m) => m.content.startsWith(filler))) {
|
||||
throw new Error("瑣事錯誤地被寫入 EpisodicMemory");
|
||||
}
|
||||
|
||||
// C-5 提取即改寫:檢索命中後提取次數遞增
|
||||
const { results: retrievedAgain } = await get(
|
||||
`/memory/${CHARACTER_ID}/retrieve?query=${encodeURIComponent("錄取通知")}`,
|
||||
);
|
||||
const secondHit = retrievedAgain.find((m) => m.id === consolidatedMemory.id);
|
||||
if (!secondHit || secondHit.retrievalCount <= consolidatedMemory.retrievalCount) {
|
||||
throw new Error("重複檢索未持續遞增提取次數");
|
||||
}
|
||||
|
||||
// C-5 遺忘:模擬時間推進,高情緒記憶不應被衰減
|
||||
const twoDaysLater = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||
await post("/memory/forgetting-sweep", { now: twoDaysLater });
|
||||
const { results: afterSweep } = await get(
|
||||
`/memory/${CHARACTER_ID}/retrieve?query=${encodeURIComponent("錄取通知")}`,
|
||||
);
|
||||
const highEmotionAfterSweep = afterSweep.find((m) => m.id === consolidatedMemory.id);
|
||||
if (!highEmotionAfterSweep || highEmotionAfterSweep.weight < secondHit.weight) {
|
||||
throw new Error("遺忘程序錯誤地影響了高情緒記憶");
|
||||
}
|
||||
|
||||
// 清理本次測試寫入的長期記憶,避免重複執行時累積髒資料
|
||||
await prisma.episodicMemory.delete({ where: { id: consolidatedMemory.id } });
|
||||
|
||||
// C-3 排程抽象層:schedule(at) 排入的工作在時間到之前不觸發、時間到之後準時觸發
|
||||
const jobId = `smoke-c-schedule-${Date.now()}`;
|
||||
await post("/memory/test-scheduled-job", { id: jobId, delaySeconds: 2 });
|
||||
|
||||
const { fired: firedTooEarly } = await get(`/memory/test-scheduled-job/${jobId}`);
|
||||
if (firedTooEarly) {
|
||||
throw new Error("schedule(at) 排入的工作在時間到之前就觸發了");
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
const { fired: firedOnTime } = await get(`/memory/test-scheduled-job/${jobId}`);
|
||||
if (!firedOnTime) {
|
||||
throw new Error("schedule(at) 排入的工作逾時未觸發");
|
||||
}
|
||||
}
|
||||
@@ -143,13 +143,19 @@ flowchart TB
|
||||
|
||||
### C. 記憶子系統
|
||||
|
||||
- [ ] **C-1 工作記憶(S)**:實作 session 對話上下文緩衝,帶 token 上限與溢位裁切策略(保留最近與高情緒段落)。驗收:超過上限時最舊的低情緒段落先被裁掉。依據:§腦區→聊天系統元件對照「工作記憶=對話上下文視窗,有 token 上限」。
|
||||
- [ ] **C-2 對話期只寫工作記憶(S)**:明確禁止對話流程中寫入長期記憶表,所有長期寫入只能由固化程序觸發。驗收:一輪對話後三張長期記憶表筆數不變。依據:§核心機制設計 1「對話中不即時寫長期記憶」。
|
||||
- [ ] **C-3 排程抽象層(S)**:定義 `JobQueue` 介面(`now` / `schedule(at)` / `every(cron)`),以 in-process 計時器實作 `InProcessJobQueue`,並在 DI 容器註冊;session 結束事件推入固化工作。驗收:排入 5 秒後的工作會準時執行。註記:R-4 換成 BullMQ 時只換實作、不動呼叫端。依據:技術選型「BullMQ:睡眠固化/離線事件/提醒排程」。
|
||||
- [ ] **C-4 睡眠固化程序(M)**:實作 `consolidate(sessionId)`——回顧整段對話,只有情緒強度高於門檻或被重複提及的內容才寫入情節/語意/程序記憶,並寫入完整 metadata。驗收:一段含「一件高情緒事件+數句閒聊」的對話固化後,只有高情緒事件進 `EpisodicMemory`。依據:§核心機制設計 1「Session 結束觸發睡眠固化」。
|
||||
- [ ] **C-5 遺忘與提取即改寫(M)**:固化程序中對「情緒強度低且長期未被提取」的記憶降權或刪除;每次檢索命中即更新提取次數與最後提取時間。驗收:模擬時間推進後低權重舊記憶被清除,常被提取者保留。依據:§核心機制設計 2「記憶遺忘(自然衰減)」。
|
||||
- [ ] **C-6 記憶檢索器(M)**:定義 `MemoryRetriever` 介面並實作關鍵字版本,排序權重=關鍵字相關度+時間近因+情緒權重+(E-6 補上的)關係對象加權。驗收:查詢命中相關情節記憶且排序符合權重設計。註記:R-2 會加上 pgvector 語意檢索實作。依據:技術選型「pgvector:記憶語意檢索」。
|
||||
- [ ] **C-V 階段驗證(XS)**:`npm run restart && npm run smoke -- C`(C.mjs:模擬一段對話 → 觸發固化 → 驗證高情緒事件入庫、瑣事未入庫、檢索可命中、提取次數遞增)。
|
||||
- [x] **C-1 工作記憶(S)**:實作 session 對話上下文緩衝,帶 token 上限與溢位裁切策略(保留最近與高情緒段落)。驗收:超過上限時最舊的低情緒段落先被裁掉。依據:§腦區→聊天系統元件對照「工作記憶=對話上下文視窗,有 token 上限」。
|
||||
- [x] **C-2 對話期只寫工作記憶(S)**:明確禁止對話流程中寫入長期記憶表,所有長期寫入只能由固化程序觸發。驗收:一輪對話後三張長期記憶表筆數不變。依據:§核心機制設計 1「對話中不即時寫長期記憶」。
|
||||
- [x] **C-3 排程抽象層(S)**:定義 `JobQueue` 介面(`now` / `schedule(at)` / `every(cron)`),以 in-process 計時器實作 `InProcessJobQueue`,並在 DI 容器註冊;session 結束事件推入固化工作。驗收:排入 5 秒後的工作會準時執行。註記:R-4 換成 BullMQ 時只換實作、不動呼叫端。依據:技術選型「BullMQ:睡眠固化/離線事件/提醒排程」。
|
||||
- [x] **C-4 睡眠固化程序(M)**:實作 `consolidate(sessionId)`——回顧整段對話,只有情緒強度高於門檻或被重複提及的內容才寫入情節/語意/程序記憶,並寫入完整 metadata。驗收:一段含「一件高情緒事件+數句閒聊」的對話固化後,只有高情緒事件進 `EpisodicMemory`。依據:§核心機制設計 1「Session 結束觸發睡眠固化」。
|
||||
- [x] **C-5 遺忘與提取即改寫(M)**:固化程序中對「情緒強度低且長期未被提取」的記憶降權或刪除;每次檢索命中即更新提取次數與最後提取時間。驗收:模擬時間推進後低權重舊記憶被清除,常被提取者保留。依據:§核心機制設計 2「記憶遺忘(自然衰減)」。
|
||||
- [x] **C-6 記憶檢索器(M)**:定義 `MemoryRetriever` 介面並實作關鍵字版本,排序權重=關鍵字相關度+時間近因+情緒權重+(E-6 補上的)關係對象加權。驗收:查詢命中相關情節記憶且排序符合權重設計。註記:R-2 會加上 pgvector 語意檢索實作。依據:技術選型「pgvector:記憶語意檢索」。
|
||||
- [x] **C-V 階段驗證(XS)**:`npm run restart && npm run smoke -- C`(C.mjs:模擬一段對話 → 觸發固化 → 驗證高情緒事件入庫、瑣事未入庫、檢索可命中、提取次數遞增)。
|
||||
|
||||
> **實作記錄(C 群組)**:
|
||||
> - 記憶引擎全放在 `apps/api/src/memory/`(NestJS module),情緒標記(`emotionTag`/`emotionIntensity`)在此階段由**呼叫端提供**,不是自己判斷——因為執行順序圖 C/D/E 是同層平行,C 不能依賴 D 尚未建置的情緒標記器;F 群組整合時才會接上 D 的真實輸出。
|
||||
> - `POST/GET /memory/:characterId/sessions/:sessionId/messages`、`.../consolidate`、`GET /memory/:characterId/retrieve`、`POST /memory/forgetting-sweep` 這幾個端點目前是**工程內部驗證用**,不是使用者可見 API;H-1「`POST /chat/:characterId` 走完整管線」「`POST /session/:id/end` 觸發睡眠固化」上線後,這些端點的邏輯會被納入正式管線,屆時再決定是否保留、改名或整組移除。
|
||||
> - 高情緒門檻(`HIGH_EMOTION_THRESHOLD = 0.6`)與工作記憶 token 上限(`DEFAULT_TOKEN_LIMIT = 200`,粗略以「字元數 / 2」估算,非真實 tokenizer)都定義在 `apps/api/src/memory/constants.ts` 與 `working-memory.service.ts`,D 群組的情緒狀態機若要共用同一門檻語意,應該從這裡匯入而不是各自定一份。
|
||||
> - `JobQueue`(`now`/`schedule(at)`/`every(cron)`)介面在 `apps/api/src/memory/job-queue.ts`,`InProcessJobQueue` 用 `setTimeout`/`node-cron` 實作;`every()` 目前唯一的呼叫者是每小時跑一次的遺忘清掃(`memory.module.ts` 的 `onModuleInit`)。J 群組的提醒排程應直接複用同一個 `JOB_QUEUE`,不要另外自己接計時器。
|
||||
|
||||
### D. 情緒子系統
|
||||
|
||||
|
||||
Reference in New Issue
Block a user