From e6f25008d1b1d66ae92f7b6ddf3a367253d55c33 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 13 Aug 2026 09:54:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20C=20=E7=BE=A4?= =?UTF-8?q?=E7=B5=84=20=E2=80=94=20=E8=A8=98=E6=86=B6=E5=AD=90=E7=B3=BB?= =?UTF-8?q?=E7=B5=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 群組冒煙測試無回歸。 --- apps/api/package.json | 4 + apps/api/src/app.module.ts | 3 +- apps/api/src/memory/constants.ts | 2 + .../src/memory/forgetting-sweep.service.ts | 44 ++++++ .../memory/in-process-job-queue.service.ts | 45 ++++++ apps/api/src/memory/job-queue.ts | 10 ++ .../memory/memory-consolidation.service.ts | 51 +++++++ .../src/memory/memory-retriever.service.ts | 69 +++++++++ apps/api/src/memory/memory.controller.ts | 82 +++++++++++ apps/api/src/memory/memory.module.ts | 41 ++++++ apps/api/src/memory/working-memory.service.ts | 61 ++++++++ package-lock.json | 20 +++ scripts/smoke/C.mjs | 136 ++++++++++++++++++ todo.md | 20 ++- 14 files changed, 580 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/memory/constants.ts create mode 100644 apps/api/src/memory/forgetting-sweep.service.ts create mode 100644 apps/api/src/memory/in-process-job-queue.service.ts create mode 100644 apps/api/src/memory/job-queue.ts create mode 100644 apps/api/src/memory/memory-consolidation.service.ts create mode 100644 apps/api/src/memory/memory-retriever.service.ts create mode 100644 apps/api/src/memory/memory.controller.ts create mode 100644 apps/api/src/memory/memory.module.ts create mode 100644 apps/api/src/memory/working-memory.service.ts create mode 100644 scripts/smoke/C.mjs diff --git a/apps/api/package.json b/apps/api/package.json index 98669f9..fc905a0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 4c8c0e8..7d4b4f9 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/memory/constants.ts b/apps/api/src/memory/constants.ts new file mode 100644 index 0000000..11b6d59 --- /dev/null +++ b/apps/api/src/memory/constants.ts @@ -0,0 +1,2 @@ +// 情緒強度視為「高情緒」的門檻(0~1),C-1 溢位裁切保護、C-4 固化判斷、C-5 遺忘判斷共用同一門檻。 +export const HIGH_EMOTION_THRESHOLD = 0.6; diff --git a/apps/api/src/memory/forgetting-sweep.service.ts b/apps/api/src/memory/forgetting-sweep.service.ts new file mode 100644 index 0000000..f1d4a90 --- /dev/null +++ b/apps/api/src/memory/forgetting-sweep.service.ts @@ -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 }; + } +} diff --git a/apps/api/src/memory/in-process-job-queue.service.ts b/apps/api/src/memory/in-process-job-queue.service.ts new file mode 100644 index 0000000..e839d51 --- /dev/null +++ b/apps/api/src/memory/in-process-job-queue.service.ts @@ -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(); + private readonly cronTasks: ReturnType[] = []; + + 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 { + try { + await handler(); + } catch (err) { + log("排程", "ERR", `工作 ${name} 執行失敗:${err instanceof Error ? err.message : String(err)}`); + } + } +} diff --git a/apps/api/src/memory/job-queue.ts b/apps/api/src/memory/job-queue.ts new file mode 100644 index 0000000..5231fcf --- /dev/null +++ b/apps/api/src/memory/job-queue.ts @@ -0,0 +1,10 @@ +export type JobHandler = () => Promise | 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"); diff --git a/apps/api/src/memory/memory-consolidation.service.ts b/apps/api/src/memory/memory-consolidation.service.ts new file mode 100644 index 0000000..1abcddb --- /dev/null +++ b/apps/api/src/memory/memory-consolidation.service.ts @@ -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 { + const entries = this.workingMemory.getContext(sessionId); + + const contentCounts = new Map(); + 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); + } +} diff --git a/apps/api/src/memory/memory-retriever.service.ts b/apps/api/src/memory/memory-retriever.service.ts new file mode 100644 index 0000000..5aa98e5 --- /dev/null +++ b/apps/api/src/memory/memory-retriever.service.ts @@ -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; +} + +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 { + 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, + })); + } +} diff --git a/apps/api/src/memory/memory.controller.ts b/apps/api/src/memory/memory.controller.ts new file mode 100644 index 0000000..b6c9e5e --- /dev/null +++ b/apps/api/src/memory/memory.controller.ts @@ -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(); + + 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 }; + } +} diff --git a/apps/api/src/memory/memory.module.ts b/apps/api/src/memory/memory.module.ts new file mode 100644 index 0000000..d6df2a1 --- /dev/null +++ b/apps/api/src/memory/memory.module.ts @@ -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(); + }); + } +} diff --git a/apps/api/src/memory/working-memory.service.ts b/apps/api/src/memory/working-memory.service.ts new file mode 100644 index 0000000..a3bdaa2 --- /dev/null +++ b/apps/api/src/memory/working-memory.service.ts @@ -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(); + 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; + } +} diff --git a/package-lock.json b/package-lock.json index 8cb475a..62d825e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/scripts/smoke/C.mjs b/scripts/smoke/C.mjs new file mode 100644 index 0000000..e25ed25 --- /dev/null +++ b/scripts/smoke/C.mjs @@ -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) 排入的工作逾時未觸發"); + } +} diff --git a/todo.md b/todo.md index afc8809..6a8eaca 100644 --- a/todo.md +++ b/todo.md @@ -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. 情緒子系統