Files
Kokorone/apps/api/src/memory/memory-retriever.service.ts
T
JefferyandClaude Sonnet 5 6125d6e27f feat: 完成 K 群組 — 對話模式:群聊與角色自聊
群聊/自聊子系統(apps/api/src/room/):在場者名冊與共用場景記錄(純記憶體,
同 C-1 工作記憶的設計)、發言權分配(點名/話題相關度/性格基線/情緒/角色間
關係/發言冷卻與冷落累積算衝動值)、群聊矜持(重用 G-3 親密度分層模板、
下修有效親密度而非另建模板庫)、隱私邊界(EpisodicMemory.isPrivate +
KeywordMemoryRetriever.excludePrivate,天然呆為口風不緊例外)、群聊記憶
投影(各角色依自身視角固化,被虧的那位權重較高)、角色自聊(沿用發言權
機制、空轉偵測注入轉折或收尾、輪數硬上限、使用者插話切換群聊)。
新增 CharacterRelationship 表供角色間關係使用。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 15:15:00 +08:00

87 lines
3.1 KiB
TypeScript

import { Injectable } from "@nestjs/common";
import type { EpisodicMemory } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js";
export interface RetrieveOptions {
limit?: number;
// E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。
relatedUserId?: string;
// K-3 隱私邊界:群聊等有旁人在場的情境傳 true,標記為私密的記憶不會被檢索到。
excludePrivate?: boolean;
}
export interface MemoryRetriever {
retrieve(characterId: string, query: string, options?: RetrieveOptions): Promise<EpisodicMemory[]>;
}
const WEIGHTS = {
keyword: 0.4,
recency: 0.25,
emotion: 0.15,
relationship: 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, options: RetrieveOptions = {}): Promise<EpisodicMemory[]> {
const limit = options.limit ?? DEFAULT_LIMIT;
const rows = await this.prisma.client.episodicMemory.findMany({
where: { characterId, ...(options.excludePrivate ? { isPrivate: false } : {}) },
});
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 relationshipScore = options.relatedUserId && row.relatedUserId === options.relatedUserId ? 1 : 0;
const score =
keywordScore * WEIGHTS.keyword +
recencyScore * WEIGHTS.recency +
emotionScore * WEIGHTS.emotion +
relationshipScore * WEIGHTS.relationship;
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,
isPrivate: row.isPrivate,
}));
}
}