import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { createPrismaClient } from "@kokorone/db"; import { afterEach, describe, expect, it } from "vitest"; import type { PrismaService } from "../prisma/prisma.service.js"; import { EmotionService } from "../emotion/emotion.service.js"; import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js"; import { RelationshipService } from "../relationship/relationship.service.js"; import { RomanceService } from "../romance/romance.service.js"; import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js"; import { AgeDeterminationService } from "../romance/age-determination.service.js"; import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js"; import { MockProvider } from "../llm/mock-provider.service.js"; import { GlobalPauseService } from "../llm/global-pause.service.js"; import { LobbyService } from "./lobby.service.js"; import { RoomEventsService } from "./room-events.service.js"; import { RoomService, USER_SPEAKER_ID } from "./room.service.js"; import { CharacterRelationshipService } from "./character-relationship.service.js"; import { RoomGenerationService } from "./room-generation.service.js"; import { RoomConsolidationService } from "./room-consolidation.service.js"; import { ControllableClock } from "../clock/controllable-clock.js"; import { SLIP_INTIMACY_PENALTY } from "./constants.js"; // T40 驗收:K-3 隱私邊界不再是沒人寫入的空殼——一般原型在群聊中不會洩漏私密(1:1)記憶; // 口風不緊原型(天然呆)偶爾會洩漏,一旦洩漏視為真實事件,對使用者的親密度給予懲罰。 const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`; const prisma = { client: createPrismaClient() } as PrismaService; const clock = new ControllableClock("2026-08-19T05:00:00.000Z"); const roomEvents = new RoomEventsService(new EventEmitter2()); const lobbyService = new LobbyService(prisma, clock, roomEvents); const roomService = new RoomService(prisma, clock, lobbyService, roomEvents); const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger()); const relationshipService = new RelationshipService(prisma, new EventEmitter2()); const retriever = new KeywordMemoryRetriever(prisma); const characterRelationship = new CharacterRelationshipService(prisma); const romanceService = new RomanceService(prisma, relationshipService, new RomanceEligibilityService(prisma, new AgeDeterminationService(prisma))); const mockProvider = new MockProvider(); const generation = new RoomGenerationService( prisma, emotionService, relationshipService, retriever, characterRelationship, romanceService, mockProvider, new GlobalPauseService(), ); const consolidation = new RoomConsolidationService(prisma, roomService, new RuleBasedEmotionTagger(), characterRelationship); const userId = "test-privacy-user"; const CHAR_DISCREET = "test-privacy-char-discreet"; // 元氣:非口風不緊 const CHAR_LOOSE = "test-privacy-char-loose"; // 天然呆:口風不緊 const CHAR_OTHER = "test-privacy-char-other"; // 房內第三人,純粹製造「有旁人在場」的群聊情境 afterEach(async () => { await prisma.client.episodicMemory.deleteMany({ where: { characterId: { in: [CHAR_DISCREET, CHAR_LOOSE, CHAR_OTHER] } }, }); await prisma.client.characterRelationship.deleteMany({ where: { characterId: { in: [CHAR_DISCREET, CHAR_LOOSE, CHAR_OTHER] } }, }); await prisma.client.relationship.deleteMany({ where: { userId } }); await prisma.client.room.deleteMany({ where: { userId } }); await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_DISCREET, CHAR_LOOSE, CHAR_OTHER] } } }); await prisma.client.user.deleteMany({ where: { id: userId } }); }); async function createCharacter(id: string, archetype: string, formalName: string) { await prisma.client.character.create({ data: { id, source: "ORIGINAL", buildStatus: "BUILT", basicInfo: "測試", backgroundStory: "測試", personalityArchetype: archetype, likesDislikes: "測試", goalsObsessions: "測試", speechStyle: "測試", aliases: { create: { formalName } }, }, }); } async function seedPrivateMemory(characterId: string) { await prisma.client.episodicMemory.create({ data: { characterId, content: "秘密基地的事只有我們兩個知道", occurredAt: clock.now(), emotionTag: "JOY", emotionIntensity: 0.9, source: "INTERACTION", weight: 1.9, isPrivate: true, }, }); } describe("隱私邊界與口風不緊(T40)", () => { it("一般原型(非口風不緊)在群聊生成中不會檢索到私密記憶,也不觸發懲罰", async () => { await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } }); await createCharacter(CHAR_DISCREET, "元氣", "小甲"); await createCharacter(CHAR_OTHER, "元氣", "小丙"); await seedPrivateMemory(CHAR_DISCREET); await relationshipService.getOrCreate(CHAR_DISCREET, userId, clock.now()); await prisma.client.relationship.update({ where: { characterId_userId: { characterId: CHAR_DISCREET, userId } }, data: { intimacy: 50 }, }); const room = await roomService.create(userId, [CHAR_DISCREET, CHAR_OTHER], "GROUP"); const reply = await generation.generateReply(room, CHAR_DISCREET, USER_SPEAKER_ID, "使用者", "秘密基地的事", { now: clock.now(), }); expect(reply.text).toBeTruthy(); const { relationship } = await relationshipService.getState(CHAR_DISCREET, userId, clock.now()); expect(relationship.intimacy).toBe(50); // 未洩漏私密記憶,親密度不受影響 }, 20000); it("口風不緊原型可能在群聊中洩漏私密記憶,洩漏時對使用者的親密度給予懲罰", async () => { await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } }); await createCharacter(CHAR_LOOSE, "天然呆", "小乙"); await createCharacter(CHAR_OTHER, "元氣", "小丙"); await seedPrivateMemory(CHAR_LOOSE); await relationshipService.getOrCreate(CHAR_LOOSE, userId, clock.now()); await prisma.client.relationship.update({ where: { characterId_userId: { characterId: CHAR_LOOSE, userId } }, data: { intimacy: 50 }, }); const room = await roomService.create(userId, [CHAR_LOOSE, CHAR_OTHER], "GROUP"); const reply = await generation.generateReply(room, CHAR_LOOSE, USER_SPEAKER_ID, "使用者", "秘密基地的事", { now: clock.now(), }); expect(reply.text).toBeTruthy(); const { relationship } = await relationshipService.getState(CHAR_LOOSE, userId, clock.now()); expect(relationship.intimacy).toBe(50 + SLIP_INTIMACY_PENALTY); // 說溜嘴:親密度扣分 }, 20000); it("K-5 各自視角固化:同一場群聊,不同角色各自萃取出的記憶內容與權重不同", async () => { await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } }); await createCharacter(CHAR_DISCREET, "元氣", "小甲"); await createCharacter(CHAR_LOOSE, "天然呆", "小乙"); const room = await roomService.create(userId, [CHAR_DISCREET, CHAR_LOOSE], "GROUP"); // 「好可愛」觸發高情緒(PRAISE,強度剛好達門檻 0.6):小甲自己說的話因情緒夠強而留存; // 小乙被提到(aboutMe)則無論情緒強度一律留存,且權重比小甲自己主觀敘述更高。 await roomService.addTurn(room.id, CHAR_DISCREET, "小甲", "小乙你好可愛,喜歡你啦!!!", clock.now()); await consolidation.consolidate(room.id); const memoriesDiscreet = await prisma.client.episodicMemory.findMany({ where: { characterId: CHAR_DISCREET } }); const memoriesLoose = await prisma.client.episodicMemory.findMany({ where: { characterId: CHAR_LOOSE } }); expect(memoriesDiscreet.length).toBeGreaterThan(0); expect(memoriesLoose.length).toBeGreaterThan(0); // 小乙被小甲提到(虧她可愛)——小乙記得這件事是「被說到自己」,權重比小甲自己主觀的敘述更高。 const looseAboutSelf = memoriesLoose.find((m) => m.content.includes("小乙你好可愛")); const discreetOwnLine = memoriesDiscreet.find((m) => m.content.includes("小乙你好可愛")); expect(looseAboutSelf).toBeTruthy(); expect(discreetOwnLine).toBeTruthy(); expect(looseAboutSelf!.weight).toBeGreaterThan(discreetOwnLine!.weight); // 群聊固化的記憶並非來自 1:1,不應標記為私密。 expect(memoriesDiscreet.every((m) => m.isPrivate === false)).toBe(true); expect(memoriesLoose.every((m) => m.isPrivate === false)).toBe(true); }, 20000); });