From 6125d6e27f0fb1888ce994bee521e7a468c3d3b9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 13 Aug 2026 15:15:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20K=20=E7=BE=A4?= =?UTF-8?q?=E7=B5=84=20=E2=80=94=20=E5=B0=8D=E8=A9=B1=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=EF=BC=9A=E7=BE=A4=E8=81=8A=E8=88=87=E8=A7=92=E8=89=B2=E8=87=AA?= =?UTF-8?q?=E8=81=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 群聊/自聊子系統(apps/api/src/room/):在場者名冊與共用場景記錄(純記憶體, 同 C-1 工作記憶的設計)、發言權分配(點名/話題相關度/性格基線/情緒/角色間 關係/發言冷卻與冷落累積算衝動值)、群聊矜持(重用 G-3 親密度分層模板、 下修有效親密度而非另建模板庫)、隱私邊界(EpisodicMemory.isPrivate + KeywordMemoryRetriever.excludePrivate,天然呆為口風不緊例外)、群聊記憶 投影(各角色依自身視角固化,被虧的那位權重較高)、角色自聊(沿用發言權 機制、空轉偵測注入轉折或收尾、輪數硬上限、使用者插話切換群聊)。 新增 CharacterRelationship 表供角色間關係使用。 Co-Authored-By: Claude Sonnet 5 --- apps/api/src/app.module.ts | 2 + apps/api/src/llm/llm.module.ts | 2 +- .../src/memory/memory-retriever.service.ts | 7 +- apps/api/src/memory/memory.controller.ts | 2 + apps/api/src/personality/archetype-params.ts | 13 +- .../room/character-relationship.service.ts | 25 +++ apps/api/src/room/constants.ts | 38 ++++ .../src/room/room-consolidation.service.ts | 87 ++++++++ apps/api/src/room/room-generation.service.ts | 112 ++++++++++ apps/api/src/room/room.controller.ts | 113 ++++++++++ apps/api/src/room/room.module.ts | 28 +++ apps/api/src/room/room.service.ts | 129 +++++++++++ apps/api/src/room/self-chat.service.ts | 127 +++++++++++ apps/api/src/room/speaking-right.service.ts | 117 ++++++++++ packages/shared/src/domain/memory.ts | 1 + .../migration.sql | 41 ++++ prisma/schema.prisma | 20 ++ scripts/smoke/K.mjs | 210 ++++++++++++++++++ todo.md | 25 ++- 19 files changed, 1088 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/room/character-relationship.service.ts create mode 100644 apps/api/src/room/constants.ts create mode 100644 apps/api/src/room/room-consolidation.service.ts create mode 100644 apps/api/src/room/room-generation.service.ts create mode 100644 apps/api/src/room/room.controller.ts create mode 100644 apps/api/src/room/room.module.ts create mode 100644 apps/api/src/room/room.service.ts create mode 100644 apps/api/src/room/self-chat.service.ts create mode 100644 apps/api/src/room/speaking-right.service.ts create mode 100644 prisma/migrations/20260813065157_add_room_and_privacy/migration.sql create mode 100644 scripts/smoke/K.mjs diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c6677dc..bbee659 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -10,6 +10,7 @@ import { PersonalityModule } from "./personality/personality.module.js"; import { ChatModule } from "./chat/chat.module.js"; import { ScheduleModule } from "./schedule/schedule.module.js"; import { TaskModule } from "./task/task.module.js"; +import { RoomModule } from "./room/room.module.js"; @Module({ imports: [ @@ -23,6 +24,7 @@ import { TaskModule } from "./task/task.module.js"; ChatModule, ScheduleModule, TaskModule, + RoomModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/llm/llm.module.ts b/apps/api/src/llm/llm.module.ts index b332fd1..3db8caa 100644 --- a/apps/api/src/llm/llm.module.ts +++ b/apps/api/src/llm/llm.module.ts @@ -38,6 +38,6 @@ function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) { ContextAssemblerService, DialogueService, ], - exports: [DialogueService, BehaviorReinforcementService], + exports: [DialogueService, BehaviorReinforcementService, LLM_PROVIDER], }) export class LlmModule {} diff --git a/apps/api/src/memory/memory-retriever.service.ts b/apps/api/src/memory/memory-retriever.service.ts index 4a6a959..c33a6d1 100644 --- a/apps/api/src/memory/memory-retriever.service.ts +++ b/apps/api/src/memory/memory-retriever.service.ts @@ -6,6 +6,8 @@ export interface RetrieveOptions { limit?: number; // E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。 relatedUserId?: string; + // K-3 隱私邊界:群聊等有旁人在場的情境傳 true,標記為私密的記憶不會被檢索到。 + excludePrivate?: boolean; } export interface MemoryRetriever { @@ -37,7 +39,9 @@ export class KeywordMemoryRetriever implements MemoryRetriever { async retrieve(characterId: string, query: string, options: RetrieveOptions = {}): Promise { const limit = options.limit ?? DEFAULT_LIMIT; - const rows = await this.prisma.client.episodicMemory.findMany({ where: { characterId } }); + 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); @@ -76,6 +80,7 @@ export class KeywordMemoryRetriever implements MemoryRetriever { lastRetrievedAt: new Date(now).toISOString(), source: row.source, weight: row.weight, + isPrivate: row.isPrivate, })); } } diff --git a/apps/api/src/memory/memory.controller.ts b/apps/api/src/memory/memory.controller.ts index 4652474..eea56d4 100644 --- a/apps/api/src/memory/memory.controller.ts +++ b/apps/api/src/memory/memory.controller.ts @@ -55,10 +55,12 @@ export class MemoryController { @Query("query") query: string, @Query("limit") limit?: string, @Query("relatedUserId") relatedUserId?: string, + @Query("excludePrivate") excludePrivate?: string, ) { const results = await this.retriever.retrieve(characterId, query ?? "", { limit: limit ? Number(limit) : undefined, relatedUserId, + excludePrivate: excludePrivate === "true", }); return { results }; } diff --git a/apps/api/src/personality/archetype-params.ts b/apps/api/src/personality/archetype-params.ts index 844d5d1..67708e6 100644 --- a/apps/api/src/personality/archetype-params.ts +++ b/apps/api/src/personality/archetype-params.ts @@ -6,7 +6,8 @@ export interface ArchetypeParams { expressiveness: number; // 情緒外顯度 0~1:極低代表幾乎不顯露(三無),極高代表全寫在臉上(元氣) trustGrowthRate: number; // 信任成長速度倍率:套用在 E-2 正向事件的信任增幅上 invertedIntimacyExpression: boolean; // 是否為傲嬌式反向表達(見 G-3) - traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6)參考 + traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6/K-3)參考 + conversationBaseline: number; // K-4 群聊發言基線 0~1:不被點名、無特別理由時主動開口的傾向(元氣高、三無極低) } const DEFAULT_PARAMS: ArchetypeParams = { @@ -15,6 +16,7 @@ const DEFAULT_PARAMS: ArchetypeParams = { trustGrowthRate: 1, invertedIntimacyExpression: false, traits: [], + conversationBaseline: 0.4, }; export const ARCHETYPE_PARAMS: Record = { @@ -24,6 +26,7 @@ export const ARCHETYPE_PARAMS: Record = { trustGrowthRate: 0.5, invertedIntimacyExpression: true, traits: ["denial-then-honest"], + conversationBaseline: 0.5, }, 冷淡: { emotionTriggerThreshold: 2.5, @@ -31,13 +34,16 @@ export const ARCHETYPE_PARAMS: Record = { trustGrowthRate: 0.3, invertedIntimacyExpression: false, traits: ["flat-affect", "short-replies"], + conversationBaseline: 0.15, }, 天然呆: { emotionTriggerThreshold: 0.7, expressiveness: 0.8, trustGrowthRate: 1.5, invertedIntimacyExpression: false, - traits: ["misreads-context"], + // K-3:口風不緊——群聊中偶爾會不小心把私密記憶說出來,是這個原型的性格設定例外。 + traits: ["misreads-context", "loose-lipped"], + conversationBaseline: 0.6, }, 元氣: { emotionTriggerThreshold: 0.7, @@ -45,6 +51,7 @@ export const ARCHETYPE_PARAMS: Record = { trustGrowthRate: 1.3, invertedIntimacyExpression: false, traits: ["talkative", "fast-emotion-decay"], + conversationBaseline: 0.85, }, 大小姐: { emotionTriggerThreshold: 1.3, @@ -52,6 +59,7 @@ export const ARCHETYPE_PARAMS: Record = { trustGrowthRate: 0.4, invertedIntimacyExpression: false, traits: ["formal-address", "poor-loser"], + conversationBaseline: 0.55, }, 三無: { emotionTriggerThreshold: 3.5, @@ -59,6 +67,7 @@ export const ARCHETYPE_PARAMS: Record = { trustGrowthRate: 0.2, invertedIntimacyExpression: false, traits: ["very-short-replies", "sudden-overflow"], + conversationBaseline: 0.05, }, }; diff --git a/apps/api/src/room/character-relationship.service.ts b/apps/api/src/room/character-relationship.service.ts new file mode 100644 index 0000000..32d4a65 --- /dev/null +++ b/apps/api/src/room/character-relationship.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; + +// K-2/K-4 角色間關係:同作品角色互損、拆台、護短、吃醋等化學反應的資料來源。 +// 有向邊:characterId 對 otherCharacterId 的觀感,兩邊可以各自設定、不必對稱。 +@Injectable() +export class CharacterRelationshipService { + constructor(private readonly prisma: PrismaService) {} + + async set(characterId: string, otherCharacterId: string, affinity: number, dynamic?: string) { + return this.prisma.client.characterRelationship.upsert({ + where: { characterId_otherCharacterId: { characterId, otherCharacterId } }, + update: { affinity, dynamic }, + create: { characterId, otherCharacterId, affinity, dynamic }, + }); + } + + // 未設定關係時,回傳中性值(50)——同作品角色預設不特別親近也不特別有心結。 + async getAffinity(characterId: string, otherCharacterId: string): Promise { + const row = await this.prisma.client.characterRelationship.findUnique({ + where: { characterId_otherCharacterId: { characterId, otherCharacterId } }, + }); + return row?.affinity ?? 50; + } +} diff --git a/apps/api/src/room/constants.ts b/apps/api/src/room/constants.ts new file mode 100644 index 0000000..f8c3410 --- /dev/null +++ b/apps/api/src/room/constants.ts @@ -0,0 +1,38 @@ +import type { EmotionTag } from "@kokorone/shared"; + +// K-4 發言權分配:各因素的權重與門檻。 +export const SPEAKING_THRESHOLD = 0.25; +export const NAMED_BOOST = 0.6; // 被點名/被提問幾乎必回 +export const BASELINE_WEIGHT = 0.3; +export const TOPIC_RELEVANCE_WEIGHT = 0.25; +export const EMOTION_WEIGHT = 0.15; +export const RELATIONSHIP_WEIGHT = 0.15; + +// 剛發言者衝動下降;被冷落的角色衝動值隨連續沉默輪數累積(有上限)。 +export const SPEAK_COOLDOWN_PENALTY = 0.35; +export const SILENT_STREAK_BONUS_PER_TURN = 0.05; +export const SILENT_STREAK_BONUS_CAP = 0.3; + +export const EMOTION_SPEAKING_MODIFIER: Record = { + CALM: 0, + JOY: 0.3, // 愉悅多話 + SAD: -0.3, // 低落沉默 + ALERT: 0.1, // 警戒時會出聲反應 + SHY: -0.1, // 害羞會退縮 + GRUMPY: -0.2, // 彆扭故意不接話 +}; + +// K-2 群聊矜持:在群聊中,實際傳入生成上下文的親密度會被下修,讓模板系統自然選到更收斂的回應。 +export const GROUP_RESERVE_INTIMACY_PENALTY = 35; +// 傲嬌式反向表達在群聊中「人前更嘴硬」,額外多下修一些。 +export const TSUNDERE_EXTRA_GROUP_PENALTY = 15; + +// K-6 角色自聊 +export const SELF_CHAT_MAX_TURNS = 12; +export const STALL_CHECK_WINDOW = 3; // 檢查最近幾輪是否開始空轉 +export const TOPIC_TWIST_LINES = [ + "對了,說到這個,我突然想到另一件事……", + "欸,先別說這個了,你們聽說了嗎——", + "話說回來,今天發生了一件蠻好玩的事。", +]; +export const SELF_CHAT_WRAP_UP_LINES = ["好啦,先聊到這裡吧,晚點再聊!", "時間也差不多了,先這樣吧~"]; diff --git a/apps/api/src/room/room-consolidation.service.ts b/apps/api/src/room/room-consolidation.service.ts new file mode 100644 index 0000000..872b64d --- /dev/null +++ b/apps/api/src/room/room-consolidation.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js"; +import { HIGH_EMOTION_THRESHOLD } from "../memory/constants.js"; +import { RoomService, USER_SPEAKER_ID, type Room, type RoomTurnRecord } from "./room.service.js"; + +async function isAboutCharacter( + prisma: PrismaService, + characterId: string, + content: string, +): Promise { + const aliases = await prisma.client.characterAlias.findMany({ where: { characterId } }); + return aliases.some( + (alias) => + (alias.formalName && content.includes(alias.formalName)) || + (alias.nickname && content.includes(alias.nickname)) || + (alias.calledByOthers && content.includes(alias.calledByOthers)), + ); +} + +// K-5 群聊記憶投影:群聊記錄是一份共用場景記錄,session 結束時各角色以自身視角萃取記憶—— +// 同一場對話,被提到/被虧的那位記得比較牢,情緒標記也可能不同(沿用 C-4 的固化風格,但改成逐角色視角)。 +@Injectable() +export class RoomConsolidationService { + constructor( + private readonly prisma: PrismaService, + private readonly room: RoomService, + private readonly tagger: RuleBasedEmotionTagger, + ) {} + + async consolidate(roomId: string): Promise { + const room = this.room.get(roomId); + for (const characterId of room.participants.keys()) { + await this.consolidateForCharacter(room, characterId); + } + this.room.end(roomId); + this.room.clear(roomId); + } + + private async consolidateForCharacter(room: Room, characterId: string): Promise { + for (const turn of room.turns) { + const isSelf = turn.speakerId === characterId; + const signal = this.tagger.tag({ text: turn.content }); + const isHighEmotion = signal.intensity >= HIGH_EMOTION_THRESHOLD; + const aboutMe = !isSelf && (await isAboutCharacter(this.prisma, characterId, turn.content)); + + if (isSelf && !isHighEmotion) { + continue; // 自己說過的話,只有情緒強烈時才特別記得(同 C-4:平淡的自述不特別留存)。 + } + if (!isSelf && !isHighEmotion && !aboutMe) { + continue; // 別人之間的閒聊,跟自己無關且情緒平淡,不特別留存。 + } + + const weight = this.weightFor(isSelf, aboutMe, signal.intensity); + await this.prisma.client.episodicMemory.create({ + data: { + characterId, + content: this.describe(turn, isSelf), + occurredAt: turn.timestamp, + emotionTag: signal.tag, + emotionIntensity: signal.intensity || 0.3, + source: "INTERACTION", + weight, + relatedUserId: turn.speakerId === USER_SPEAKER_ID ? room.userId : undefined, + }, + }); + } + } + + // 被提到/被虧的那位記得比較牢:aboutMe 的權重高於單純旁觀到的高情緒對話。 + private weightFor(isSelf: boolean, aboutMe: boolean, intensity: number): number { + if (aboutMe) { + return 1.5 + intensity; + } + if (isSelf) { + return 1 + intensity; + } + return 0.5 + intensity; + } + + private describe(turn: RoomTurnRecord, isSelf: boolean): string { + if (isSelf) { + return `我說:「${turn.content}」`; + } + return `${turn.speakerLabel}說:「${turn.content}」`; + } +} diff --git a/apps/api/src/room/room-generation.service.ts b/apps/api/src/room/room-generation.service.ts new file mode 100644 index 0000000..e8e1925 --- /dev/null +++ b/apps/api/src/room/room-generation.service.ts @@ -0,0 +1,112 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { EmotionService, dominantState } from "../emotion/emotion.service.js"; +import { toResponseStyle } from "../emotion/response-style.js"; +import { RelationshipService } from "../relationship/relationship.service.js"; +import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js"; +import { LLM_PROVIDER, type LLMProvider } from "../llm/llm-provider.js"; +import { hashToSeed } from "../llm/seeded-random.js"; +import type { GenerationContext, GeneratedResponse } from "../llm/types.js"; +import { getArchetypeParams } from "../personality/archetype-params.js"; +import { resolveAddress } from "../personality/language-style.js"; +import { stageForIntimacy } from "../relationship/constants.js"; +import { CharacterRelationshipService } from "./character-relationship.service.js"; +import { USER_SPEAKER_ID, type Room } from "./room.service.js"; +import { GROUP_RESERVE_INTIMACY_PENALTY, TSUNDERE_EXTRA_GROUP_PENALTY } from "./constants.js"; + +export interface RoomReplyOptions { + seed?: number; + now?: Date; +} + +export interface RoomReply extends GeneratedResponse { + address: string; +} + +// K-2/K-3 群聊生成:一律套用「人前矜持」的親密度下修+隱私過濾,一對一聊天不會走這條路徑。 +@Injectable() +export class RoomGenerationService { + constructor( + private readonly prisma: PrismaService, + private readonly emotion: EmotionService, + private readonly relationship: RelationshipService, + private readonly retriever: KeywordMemoryRetriever, + private readonly characterRelationship: CharacterRelationshipService, + @Inject(LLM_PROVIDER) private readonly provider: LLMProvider, + ) {} + + async generateReply( + room: Room, + characterId: string, + speakerId: string, + speakerLabel: string, + userInput: string, + options: RoomReplyOptions = {}, + ): Promise { + const now = options.now ?? new Date(); + const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }); + const archetypeParams = getArchetypeParams(character.personalityArchetype); + + const emotionState = await this.emotion.getState(characterId, now); + const dominant = dominantState(emotionState); + const style = toResponseStyle(dominant); + + // 對使用者發言沿用 G-2 的稱呼分層;對其他角色發言則直接以名字稱呼(NPC 之間不走使用者稱呼分層)。 + let baseIntimacy: number; + let trust: number; + let address: string; + if (speakerId === USER_SPEAKER_ID) { + const { relationship } = await this.relationship.getState(characterId, room.userId, now); + baseIntimacy = relationship.intimacy; + trust = relationship.trust; + address = resolveAddress(speakerLabel, relationship.intimacy); + } else { + baseIntimacy = await this.characterRelationship.getAffinity(characterId, speakerId); + trust = baseIntimacy; + address = speakerLabel; + } + + // K-2 群聊矜持:實際餵給生成的親密度下修,讓既有的模板分層(intimacyTier)自然選到更收斂的回應; + // 傲嬌式反向表達在人前更嘴硬,額外多下修一些。 + let effectiveIntimacy = baseIntimacy - GROUP_RESERVE_INTIMACY_PENALTY; + if (archetypeParams.invertedIntimacyExpression) { + effectiveIntimacy -= TSUNDERE_EXTRA_GROUP_PENALTY; + } + effectiveIntimacy = Math.max(0, Math.min(100, effectiveIntimacy)); + + // K-3 隱私邊界:有旁人在場,私密記憶不主動洩漏(口風不緊原型除外)。 + const excludePrivate = !archetypeParams.traits.includes("loose-lipped"); + const retrievedMemories = await this.retriever.retrieve(characterId, userInput, { + relatedUserId: room.userId, + excludePrivate, + }); + + const history = room.turns.slice(-10).map((turn) => ({ + role: (turn.speakerId === USER_SPEAKER_ID ? "user" : "character") as "user" | "character", + content: `${turn.speakerLabel}:${turn.content}`, + timestamp: turn.timestamp.toISOString(), + })); + + const seed = options.seed ?? hashToSeed(`${room.id}:${room.turnCounter}:${characterId}`); + + const context: GenerationContext = { + character: { + id: character.id, + personalityArchetype: character.personalityArchetype, + speechStyle: character.speechStyle, + likesDislikes: character.likesDislikes, + basicInfo: character.basicInfo, + }, + emotion: { dominant, style }, + relationship: { intimacy: effectiveIntimacy, trust, stage: stageForIntimacy(effectiveIntimacy) }, + retrievedMemories, + history, + userInput, + injectedMemoryIds: retrievedMemories.map((memory) => memory.id), + seed, + }; + + const generated = await this.provider.generate(context); + return { text: `${address},${generated.text}`, actions: generated.actions, address }; + } +} diff --git a/apps/api/src/room/room.controller.ts b/apps/api/src/room/room.controller.ts new file mode 100644 index 0000000..b41f6f6 --- /dev/null +++ b/apps/api/src/room/room.controller.ts @@ -0,0 +1,113 @@ +import { Body, Controller, Get, Param, Post } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { RoomService, USER_SPEAKER_ID } from "./room.service.js"; +import { SpeakingRightService } from "./speaking-right.service.js"; +import { RoomGenerationService } from "./room-generation.service.js"; +import { RoomConsolidationService } from "./room-consolidation.service.js"; +import { SelfChatService } from "./self-chat.service.js"; +import { CharacterRelationshipService } from "./character-relationship.service.js"; + +interface CreateRoomBody { + userId: string; + characterIds: string[]; + mode?: "GROUP" | "SELF_CHAT"; + topicSeed?: string; +} + +interface MessageBody { + userId: string; + userLabel?: string; + text: string; + now?: string; +} + +interface RelationshipBody { + otherCharacterId: string; + affinity: number; + dynamic?: string; +} + +@Controller("room") +export class RoomController { + constructor( + private readonly prisma: PrismaService, + private readonly room: RoomService, + private readonly speakingRight: SpeakingRightService, + private readonly generation: RoomGenerationService, + private readonly consolidation: RoomConsolidationService, + private readonly selfChat: SelfChatService, + private readonly characterRelationship: CharacterRelationshipService, + ) {} + + @Post() + create(@Body() body: CreateRoomBody) { + const room = this.room.create(body.userId, body.characterIds, body.mode ?? "GROUP", body.topicSeed); + return { id: room.id, mode: room.mode, status: room.status, topicSeed: room.topicSeed }; + } + + // K-1 驗收:角色(或呼叫端)可取得完整在場名單。 + @Get(":roomId") + get(@Param("roomId") roomId: string) { + const room = this.room.get(roomId); + return { + id: room.id, + mode: room.mode, + status: room.status, + topicSeed: room.topicSeed, + participants: this.room.roster(roomId), + turnCount: room.turnCounter, + turns: room.turns.map((turn) => ({ speakerId: turn.speakerId, speakerLabel: turn.speakerLabel, content: turn.content })), + }; + } + + // K-2/K-4 群聊訊息:使用者發言後,依發言權分配決定哪些角色本輪回應,依衝動值高到低依序生成。 + @Post(":roomId/message") + async message(@Param("roomId") roomId: string, @Body() body: MessageBody) { + const now = body.now ? new Date(body.now) : new Date(); + const room = this.room.get(roomId); + const userLabel = body.userLabel ?? body.userId; + + this.room.addTurn(roomId, USER_SPEAKER_ID, userLabel, body.text, now); + + const speakers = await this.speakingRight.decideSpeakers(room, USER_SPEAKER_ID, body.text, now); + const replies: { characterId: string; text: string; score: number }[] = []; + for (const candidate of speakers) { + const character = await this.prisma.client.character.findUniqueOrThrow({ + where: { id: candidate.characterId }, + }); + const alias = await this.prisma.client.characterAlias.findFirst({ + where: { characterId: candidate.characterId }, + }); + const speakerLabel = alias?.nickname || alias?.formalName || character.id; + const reply = await this.generation.generateReply(room, candidate.characterId, USER_SPEAKER_ID, userLabel, body.text, { + now, + }); + this.room.addTurn(roomId, candidate.characterId, speakerLabel, reply.text, now); + replies.push({ characterId: candidate.characterId, text: reply.text, score: candidate.score }); + } + + return { speakers: replies }; + } + + @Post(":roomId/self-chat/advance") + async advanceSelfChat(@Param("roomId") roomId: string, @Body() body: { now?: string }) { + return this.selfChat.advance(roomId, body.now ? new Date(body.now) : undefined); + } + + @Post(":roomId/interject") + async interject(@Param("roomId") roomId: string, @Body() body: MessageBody) { + await this.selfChat.interject(roomId); + return this.message(roomId, body); + } + + @Post(":roomId/end") + async end(@Param("roomId") roomId: string) { + await this.consolidation.consolidate(roomId); + return { ok: true }; + } + + @Post("relationship/:characterId") + async setRelationship(@Param("characterId") characterId: string, @Body() body: RelationshipBody) { + return this.characterRelationship.set(characterId, body.otherCharacterId, body.affinity, body.dynamic); + } +} diff --git a/apps/api/src/room/room.module.ts b/apps/api/src/room/room.module.ts new file mode 100644 index 0000000..e188d07 --- /dev/null +++ b/apps/api/src/room/room.module.ts @@ -0,0 +1,28 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "../prisma/prisma.module.js"; +import { EmotionModule } from "../emotion/emotion.module.js"; +import { RelationshipModule } from "../relationship/relationship.module.js"; +import { MemoryModule } from "../memory/memory.module.js"; +import { LlmModule } from "../llm/llm.module.js"; +import { RoomService } from "./room.service.js"; +import { CharacterRelationshipService } from "./character-relationship.service.js"; +import { SpeakingRightService } from "./speaking-right.service.js"; +import { RoomGenerationService } from "./room-generation.service.js"; +import { RoomConsolidationService } from "./room-consolidation.service.js"; +import { SelfChatService } from "./self-chat.service.js"; +import { RoomController } from "./room.controller.js"; + +@Module({ + imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule, LlmModule], + controllers: [RoomController], + providers: [ + RoomService, + CharacterRelationshipService, + SpeakingRightService, + RoomGenerationService, + RoomConsolidationService, + SelfChatService, + ], + exports: [RoomService, CharacterRelationshipService, SpeakingRightService, RoomGenerationService, RoomConsolidationService, SelfChatService], +}) +export class RoomModule {} diff --git a/apps/api/src/room/room.service.ts b/apps/api/src/room/room.service.ts new file mode 100644 index 0000000..4e76c51 --- /dev/null +++ b/apps/api/src/room/room.service.ts @@ -0,0 +1,129 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import type { EmotionTag } from "@kokorone/shared"; + +export type RoomMode = "GROUP" | "SELF_CHAT"; +export type RoomStatus = "ACTIVE" | "ENDED"; +export const USER_SPEAKER_ID = "USER"; + +export interface RoomParticipantState { + characterId: string; + silentStreak: number; + lastSpokeAtTurn: number; // -1 = 從未發言 +} + +export interface RoomTurnRecord { + turnIndex: number; + speakerId: string; // characterId,或使用者固定用 USER_SPEAKER_ID + speakerLabel: string; + content: string; + emotionTag?: EmotionTag; + emotionIntensity?: number; + timestamp: Date; +} + +export interface Room { + id: string; + userId: string; + mode: RoomMode; + status: RoomStatus; + topicSeed?: string; + participants: Map; + turns: RoomTurnRecord[]; + turnCounter: number; +} + +let roomSeq = 0; +function nextRoomId(): string { + roomSeq += 1; + return `room-${roomSeq}-${roomSeq * 31 + 7}`; +} + +// K-1 在場者模型:群聊/自聊 session 的參與者名冊與共用場景記錄。 +// 刻意採用純記憶體儲存(同 C-1 WorkingMemoryService 的設計),因為這是 session 期間的暫存場景, +// session 結束後會由 K-5 的固化流程萃取成各角色的長期記憶,原始記錄本身不需要落地保存。 +@Injectable() +export class RoomService { + private readonly rooms = new Map(); + + create(userId: string, characterIds: string[], mode: RoomMode, topicSeed?: string): Room { + const room: Room = { + id: nextRoomId(), + userId, + mode, + status: "ACTIVE", + topicSeed, + participants: new Map( + characterIds.map((characterId) => [characterId, { characterId, silentStreak: 0, lastSpokeAtTurn: -1 }]), + ), + turns: [], + turnCounter: 0, + }; + this.rooms.set(room.id, room); + return room; + } + + get(roomId: string): Room { + const room = this.rooms.get(roomId); + if (!room) { + throw new NotFoundException(`聊天室不存在:${roomId}`); + } + return room; + } + + // K-1 驗收:角色(或呼叫端)可取得完整在場名單。 + roster(roomId: string): string[] { + return [...this.get(roomId).participants.keys()]; + } + + addTurn( + roomId: string, + speakerId: string, + speakerLabel: string, + content: string, + now: Date = new Date(), + emotion?: { tag: EmotionTag; intensity: number }, + ): RoomTurnRecord { + const room = this.get(roomId); + const turnIndex = room.turnCounter; + room.turnCounter += 1; + + const turn: RoomTurnRecord = { + turnIndex, + speakerId, + speakerLabel, + content, + emotionTag: emotion?.tag, + emotionIntensity: emotion?.intensity, + timestamp: now, + }; + room.turns.push(turn); + + // 發言冷卻/被冷落累積:發言者本輪清空沉默計數,其他在場角色沉默計數 +1。 + for (const participant of room.participants.values()) { + if (participant.characterId === speakerId) { + participant.lastSpokeAtTurn = turnIndex; + participant.silentStreak = 0; + } else { + participant.silentStreak += 1; + } + } + + return turn; + } + + switchToGroup(roomId: string): Room { + const room = this.get(roomId); + room.mode = "GROUP"; + return room; + } + + end(roomId: string): Room { + const room = this.get(roomId); + room.status = "ENDED"; + return room; + } + + clear(roomId: string): void { + this.rooms.delete(roomId); + } +} diff --git a/apps/api/src/room/self-chat.service.ts b/apps/api/src/room/self-chat.service.ts new file mode 100644 index 0000000..964e735 --- /dev/null +++ b/apps/api/src/room/self-chat.service.ts @@ -0,0 +1,127 @@ +import { Injectable } from "@nestjs/common"; +import { RoomService, USER_SPEAKER_ID, type Room } from "./room.service.js"; +import { SpeakingRightService } from "./speaking-right.service.js"; +import { RoomGenerationService } from "./room-generation.service.js"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { SELF_CHAT_MAX_TURNS, STALL_CHECK_WINDOW, TOPIC_TWIST_LINES, SELF_CHAT_WRAP_UP_LINES } from "./constants.js"; + +function normalize(text: string): string { + return text.trim().toLowerCase(); +} + +// 偵測最近幾輪是否開始空轉:內容高度重複,或字數持續下降(話越講越少,快聊不下去了)。 +function isStalling(recentContents: string[]): boolean { + if (recentContents.length < STALL_CHECK_WINDOW) { + return false; + } + const window = recentContents.slice(-STALL_CHECK_WINDOW); + const normalized = window.map(normalize); + const uniqueCount = new Set(normalized).size; + if (uniqueCount === 1) { + return true; // 完全重複 + } + const lengths = window.map((text) => text.length); + const isMonotonicDecline = lengths.every((length, index) => index === 0 || length <= lengths[index - 1]); + return isMonotonicDecline && lengths[0] > lengths[lengths.length - 1]; +} + +export interface SelfChatAdvanceResult { + ended: boolean; + turn?: { characterId: string; content: string }; + reason?: "topic-exhausted" | "turn-cap" | "stalled-out"; +} + +// K-6 角色自聊:話題種子驅動、發言權機制沿用 K-4、空轉偵測注入轉折或收尾、輪數硬上限。 +@Injectable() +export class SelfChatService { + private readonly twistCounts = new Map(); + + constructor( + private readonly prisma: PrismaService, + private readonly room: RoomService, + private readonly speakingRight: SpeakingRightService, + private readonly generation: RoomGenerationService, + ) {} + + async start(userId: string, characterIds: string[], topicSeed: string, now: Date = new Date()) { + const room = this.room.create(userId, characterIds, "SELF_CHAT", topicSeed); + this.twistCounts.set(room.id, 0); + return room; + } + + async advance(roomId: string, now: Date = new Date()): Promise { + const room = this.room.get(roomId); + if (room.mode !== "SELF_CHAT" || room.status !== "ACTIVE") { + return { ended: true, reason: "topic-exhausted" }; + } + + if (room.turnCounter >= SELF_CHAT_MAX_TURNS) { + return this.wrapUp(room, now); + } + + const lastTurn = room.turns.at(-1); + const triggerSpeakerId = lastTurn?.speakerId ?? "__SEED__"; + const triggerContent = lastTurn?.content ?? room.topicSeed ?? "今天過得怎麼樣?"; + const triggerLabel = lastTurn?.speakerLabel ?? "話題"; + + const recentContents = room.turns.slice(-STALL_CHECK_WINDOW).map((turn) => turn.content); + const twistCount = this.twistCounts.get(room.id) ?? 0; + if (isStalling(recentContents)) { + if (twistCount >= 2) { + return this.wrapUp(room, now); + } + return this.injectTwist(room, twistCount, now); + } + + const candidates = await this.speakingRight.decideSpeakers(room, triggerSpeakerId, triggerContent, now); + const speakerId = candidates[0]?.characterId ?? [...room.participants.keys()][0]; + if (!speakerId) { + return this.wrapUp(room, now); + } + + const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: speakerId } }); + const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: speakerId } }); + const speakerLabel = alias?.nickname || alias?.formalName || character.id; + + const reply = await this.generation.generateReply(room, speakerId, triggerSpeakerId, triggerLabel, triggerContent, { + now, + }); + this.room.addTurn(roomId, speakerId, speakerLabel, reply.text, now); + + return { ended: false, turn: { characterId: speakerId, content: reply.text } }; + } + + // 使用者插話:切換為群聊模式,插話內容本身視為使用者的第一則群聊訊息。 + async interject(roomId: string): Promise { + return this.room.switchToGroup(roomId); + } + + private async injectTwist(room: Room, twistCount: number, now: Date): Promise { + const speakerId = [...room.participants.keys()][twistCount % room.participants.size]; + const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: speakerId } }); + const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: speakerId } }); + const speakerLabel = alias?.nickname || alias?.formalName || character.id; + const line = TOPIC_TWIST_LINES[twistCount % TOPIC_TWIST_LINES.length]; + + this.room.addTurn(room.id, speakerId, speakerLabel, line, now); + this.twistCounts.set(room.id, twistCount + 1); + return { ended: false, turn: { characterId: speakerId, content: line } }; + } + + private async wrapUp(room: Room, now: Date): Promise { + const speakerId = [...room.participants.keys()][0]; + if (speakerId) { + const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: speakerId } }); + const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: speakerId } }); + const speakerLabel = alias?.nickname || alias?.formalName || character.id; + const line = SELF_CHAT_WRAP_UP_LINES[room.turnCounter % SELF_CHAT_WRAP_UP_LINES.length]; + this.room.addTurn(room.id, speakerId, speakerLabel, line, now); + } + this.twistCounts.delete(room.id); + this.room.end(room.id); + return { + ended: true, + reason: room.turnCounter >= SELF_CHAT_MAX_TURNS ? "turn-cap" : "stalled-out", + }; + } +} diff --git a/apps/api/src/room/speaking-right.service.ts b/apps/api/src/room/speaking-right.service.ts new file mode 100644 index 0000000..ee14dc9 --- /dev/null +++ b/apps/api/src/room/speaking-right.service.ts @@ -0,0 +1,117 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { EmotionService, dominantState } from "../emotion/emotion.service.js"; +import { RelationshipService } from "../relationship/relationship.service.js"; +import { getArchetypeParams } from "../personality/archetype-params.js"; +import { CharacterRelationshipService } from "./character-relationship.service.js"; +import { type Room, USER_SPEAKER_ID } from "./room.service.js"; +import { + SPEAKING_THRESHOLD, + NAMED_BOOST, + BASELINE_WEIGHT, + TOPIC_RELEVANCE_WEIGHT, + EMOTION_WEIGHT, + RELATIONSHIP_WEIGHT, + SPEAK_COOLDOWN_PENALTY, + SILENT_STREAK_BONUS_PER_TURN, + SILENT_STREAK_BONUS_CAP, + EMOTION_SPEAKING_MODIFIER, +} from "./constants.js"; + +export interface SpeakingImpulse { + characterId: string; + score: number; + isNamed: boolean; +} + +function keywordOverlap(text: string, corpus: string): number { + // 中文常見以單字成詞(貓/狗/書),門檻不能設 >=2,否則會漏掉大量真實關鍵字。 + const keywords = corpus.split(/[、,,;::\s]+/).filter((word) => word.length >= 1); + if (keywords.length === 0) { + return 0; + } + const hits = keywords.filter((keyword) => text.includes(keyword)).length; + if (hits === 0) { + return 0; + } + // 只要命中,就是明確訊號(哪怕只命中角色喜好清單裡的一個詞):至少給予下限分數, + // 不讓「喜好清單條目多」稀釋掉單次命中的重要性。 + return Math.max(0.5, hits / keywords.length); +} + +// K-4 發言權分配:每輪為每位在場角色計算「發言衝動值」,超過門檻者本輪發言。 +@Injectable() +export class SpeakingRightService { + constructor( + private readonly prisma: PrismaService, + private readonly emotion: EmotionService, + private readonly relationship: RelationshipService, + private readonly characterRelationship: CharacterRelationshipService, + ) {} + + async computeImpulse( + room: Room, + characterId: string, + speakerId: string, + content: string, + now: Date, + ): Promise { + const [character, aliases] = await Promise.all([ + this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }), + this.prisma.client.characterAlias.findMany({ where: { characterId } }), + ]); + const archetypeParams = getArchetypeParams(character.personalityArchetype); + + const isNamed = aliases.some( + (alias) => + (alias.formalName && content.includes(alias.formalName)) || + (alias.nickname && content.includes(alias.nickname)) || + (alias.calledByOthers && content.includes(alias.calledByOthers)), + ); + + const topicCorpus = [character.likesDislikes, character.goalsObsessions, character.basicInfo].join("、"); + const topicRelevance = keywordOverlap(content, topicCorpus); + + const emotionState = await this.emotion.getState(characterId, now); + const emotionModifier = EMOTION_SPEAKING_MODIFIER[dominantState(emotionState)]; + + let relationshipModifier = 0; + if (speakerId === USER_SPEAKER_ID) { + const { relationship } = await this.relationship.getState(characterId, room.userId, now); + relationshipModifier = (relationship.intimacy - 50) / 100; + } else if (speakerId !== characterId) { + const affinity = await this.characterRelationship.getAffinity(characterId, speakerId); + relationshipModifier = (affinity - 50) / 100; + } + + const participant = room.participants.get(characterId); + const justSpoke = participant?.lastSpokeAtTurn === room.turnCounter - 1; + const cooldownPenalty = justSpoke ? SPEAK_COOLDOWN_PENALTY : 0; + const silentStreakBonus = Math.min( + SILENT_STREAK_BONUS_CAP, + (participant?.silentStreak ?? 0) * SILENT_STREAK_BONUS_PER_TURN, + ); + + let score = + archetypeParams.conversationBaseline * BASELINE_WEIGHT + + topicRelevance * TOPIC_RELEVANCE_WEIGHT + + emotionModifier * EMOTION_WEIGHT + + relationshipModifier * RELATIONSHIP_WEIGHT + + silentStreakBonus - + cooldownPenalty; + if (isNamed) { + score += NAMED_BOOST; + } + + return { characterId, score: Math.max(0, Math.min(1.6, score)), isNamed }; + } + + // 回傳本輪應該發言的角色(依衝動值高到低排序),排除發言者本人。 + async decideSpeakers(room: Room, speakerId: string, content: string, now: Date = new Date()): Promise { + const candidates = [...room.participants.keys()].filter((characterId) => characterId !== speakerId); + const impulses = await Promise.all( + candidates.map((characterId) => this.computeImpulse(room, characterId, speakerId, content, now)), + ); + return impulses.filter((impulse) => impulse.score >= SPEAKING_THRESHOLD).sort((a, b) => b.score - a.score); + } +} diff --git a/packages/shared/src/domain/memory.ts b/packages/shared/src/domain/memory.ts index 2f8131c..049e0ef 100644 --- a/packages/shared/src/domain/memory.ts +++ b/packages/shared/src/domain/memory.ts @@ -13,6 +13,7 @@ export interface EpisodicMemory { lastRetrievedAt?: string | null; source: MemorySource; weight: number; + isPrivate: boolean; } export interface SemanticMemory { diff --git a/prisma/migrations/20260813065157_add_room_and_privacy/migration.sql b/prisma/migrations/20260813065157_add_room_and_privacy/migration.sql new file mode 100644 index 0000000..f1ddacd --- /dev/null +++ b/prisma/migrations/20260813065157_add_room_and_privacy/migration.sql @@ -0,0 +1,41 @@ +-- CreateTable +CREATE TABLE "character_relationships" ( + "id" TEXT NOT NULL PRIMARY KEY, + "characterId" TEXT NOT NULL, + "otherCharacterId" TEXT NOT NULL, + "affinity" INTEGER NOT NULL DEFAULT 50, + "dynamic" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "character_relationships_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "character_relationships_otherCharacterId_fkey" FOREIGN KEY ("otherCharacterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_episodic_memories" ( + "id" TEXT NOT NULL PRIMARY KEY, + "characterId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "occurredAt" DATETIME NOT NULL, + "emotionTag" TEXT NOT NULL, + "emotionIntensity" REAL NOT NULL, + "retrievalCount" INTEGER NOT NULL DEFAULT 0, + "lastRetrievedAt" DATETIME, + "source" TEXT NOT NULL, + "weight" REAL NOT NULL DEFAULT 1, + "relatedUserId" TEXT, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "episodic_memories_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "episodic_memories_relatedUserId_fkey" FOREIGN KEY ("relatedUserId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_episodic_memories" ("characterId", "content", "createdAt", "emotionIntensity", "emotionTag", "id", "lastRetrievedAt", "occurredAt", "relatedUserId", "retrievalCount", "source", "weight") SELECT "characterId", "content", "createdAt", "emotionIntensity", "emotionTag", "id", "lastRetrievedAt", "occurredAt", "relatedUserId", "retrievalCount", "source", "weight" FROM "episodic_memories"; +DROP TABLE "episodic_memories"; +ALTER TABLE "new_episodic_memories" RENAME TO "episodic_memories"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "character_relationships_characterId_otherCharacterId_key" ON "character_relationships"("characterId", "otherCharacterId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 402a858..36b2816 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -134,10 +134,29 @@ model Character { scheduleExceptions ScheduleExceptionLog[] tasks Task[] proactiveCareLogs ProactiveCareLog[] + relationshipsToOthers CharacterRelationship[] @relation("RelationshipFrom") + relationshipsFromOthers CharacterRelationship[] @relation("RelationshipTo") @@map("characters") } +// K-2/K-4 角色間關係:群聊中「角色間關係上場」與發言權分配的「與發言者的關係」都吃這張表。 +// 有向邊(characterId 對 otherCharacterId 的觀感),故同作品兩角色互相的觀感可以不對稱。 +model CharacterRelationship { + id String @id @default(cuid()) + characterId String + character Character @relation("RelationshipFrom", fields: [characterId], references: [id], onDelete: Cascade) + otherCharacterId String + otherCharacter Character @relation("RelationshipTo", fields: [otherCharacterId], references: [id], onDelete: Cascade) + affinity Int @default(50) // 0~100,越高越要好;50 為中性 + dynamic String? // 互動基調描述性標籤,例如 loyal-friend/rival/protective/crush + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([characterId, otherCharacterId]) + @@map("character_relationships") +} + // G-4 反差萌稀有度控制:記錄每次反差行為觸發的時間,供冷卻期判定。 model ContrastTriggerLog { id String @id @default(cuid()) @@ -210,6 +229,7 @@ model EpisodicMemory { weight Float @default(1) // 權重(供遺忘/檢索排序使用) relatedUserId String? // 這段記憶與哪位使用者相關(E-5 關係加權檢索用) relatedUser User? @relation(fields: [relatedUserId], references: [id], onDelete: SetNull) + isPrivate Boolean @default(false) // K-3 隱私邊界:一對一聊過的私密內容,群聊中不主動洩漏(口風不緊原型除外) createdAt DateTime @default(now()) @@map("episodic_memories") diff --git a/scripts/smoke/K.mjs b/scripts/smoke/K.mjs new file mode 100644 index 0000000..847577c --- /dev/null +++ b/scripts/smoke/K.mjs @@ -0,0 +1,210 @@ +import { prisma } from "@kokorone/db"; + +const API_PORT = process.env.PORT_API ?? "3001"; +const USER_ID = "seed-user-primary"; +const GENKI_ID = "smoke-k-genki"; +const TSUNDERE_ID = "smoke-k-tsundere"; +const COOL_ID = "smoke-k-cool"; + +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}:${await res.text()}`); + } + 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 createCharacter(id, formalName, archetype, likesDislikes, intimacy) { + await post("/personality/characters", { + id, + source: "ORIGINAL", + buildStatus: "BUILT", + formalName, + basicInfo: `${formalName},測試角色`, + backgroundStory: "測試用背景", + personalityArchetype: archetype, + likesDislikes, + goalsObsessions: "測試", + speechStyle: "第一人稱「我」", + initialRelationship: { userId: USER_ID, intimacy, trust: intimacy }, + }); +} + +export default async function smokeK() { + await prisma.character.deleteMany({ where: { id: { in: [GENKI_ID, TSUNDERE_ID, COOL_ID] } } }); + await createCharacter(GENKI_ID, "小元", "元氣", "喜歡:運動、音樂;討厭:早起", 70); + await createCharacter(TSUNDERE_ID, "小傲", "傲嬌", "喜歡:貓;討厭:被嘲笑", 50); + await createCharacter(COOL_ID, "小冷", "三無", "喜歡:安靜;討厭:吵鬧", 50); + + // K-1 在場者模型:群聊 session 內每個角色都能取得完整在場名單。 + const room = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID, TSUNDERE_ID, COOL_ID], mode: "GROUP" }); + const roomDetail = await get(`/room/${room.id}`); + const roster = new Set(roomDetail.participants); + if (!roster.has(GENKI_ID) || !roster.has(TSUNDERE_ID) || !roster.has(COOL_ID) || roster.size !== 3) { + throw new Error(`在場名單應包含所有三個角色,實際為 ${JSON.stringify(roomDetail.participants)}`); + } + + // K-4 發言權分配:不點名的閒聊,只有基線最高的元氣角色會主動開口,其他角色保持沉默。 + const genericRoom = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID, TSUNDERE_ID, COOL_ID], mode: "GROUP" }); + const genericReply = await post(`/room/${genericRoom.id}/message`, { + userId: USER_ID, + userLabel: "小明", + text: "今天大家過得怎麼樣?", + }); + const genericSpeakerIds = genericReply.speakers.map((s) => s.characterId); + if (!genericSpeakerIds.includes(GENKI_ID)) { + throw new Error("元氣角色基線高,不點名的閒聊也應該主動開口"); + } + if (genericSpeakerIds.includes(COOL_ID)) { + throw new Error("三無角色基線極低,不點名不該主動開口"); + } + + // 被點名(提及正式名)幾乎必回,且衝動值應該是本輪最高。 + const namedRoom = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID, TSUNDERE_ID, COOL_ID], mode: "GROUP" }); + const namedReply = await post(`/room/${namedRoom.id}/message`, { + userId: USER_ID, + userLabel: "小明", + text: "小冷,你今天有出去玩嗎?", + }); + const coolSpeaker = namedReply.speakers.find((s) => s.characterId === COOL_ID); + if (!coolSpeaker) { + throw new Error("被明確點名時,該角色應該回應"); + } + const maxScore = Math.max(...namedReply.speakers.map((s) => s.score)); + if (coolSpeaker.score !== maxScore) { + throw new Error("被點名的角色本輪衝動值應該是最高的"); + } + + // 話題相關度:提到「貓」應該提升喜歡貓的傲嬌角色的發言衝動。 + const topicRoom = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID, TSUNDERE_ID, COOL_ID], mode: "GROUP" }); + const topicReply = await post(`/room/${topicRoom.id}/message`, { + userId: USER_ID, + userLabel: "小明", + text: "我今天在路上看到一隻好可愛的貓", + }); + if (!topicReply.speakers.some((s) => s.characterId === TSUNDERE_ID)) { + throw new Error("話題與角色喜好相關時,應該提升其發言衝動並促使發言"); + } + + // K-2 群聊行為變化:同角色、同輸入,在一對一與群聊應輸出不同(群聊更矜持,命中不同親密度分層的模板)。 + const oneOnOneReply = await post(`/chat/${GENKI_ID}`, { + userId: USER_ID, + sessionId: "smoke-k-11-session", + text: "今天過得如何呀", + }); + const groupIntimacyRoom = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID], mode: "GROUP" }); + const groupReply = await post(`/room/${groupIntimacyRoom.id}/message`, { + userId: USER_ID, + userLabel: "小明", + text: "今天過得如何呀", + }); + if (oneOnOneReply.text === groupReply.speakers[0].text) { + throw new Error("同角色同情境在一對一與群聊應該輸出不同(群聊應更矜持)"); + } + + // K-3 隱私邊界:標記為私密的記憶,群聊情境(excludePrivate)不應被檢索到;一般情境仍可檢索。 + await prisma.episodicMemory.create({ + data: { + characterId: GENKI_ID, + content: "使用者偷偷告訴我他其實很怕黑", + occurredAt: new Date(), + emotionTag: "CALM", + emotionIntensity: 0.5, + source: "INTERACTION", + isPrivate: true, + relatedUserId: USER_ID, + }, + }); + const privateIncluded = await get(`/memory/${GENKI_ID}/retrieve?query=${encodeURIComponent("怕黑")}`); + if (!privateIncluded.results.some((m) => m.content.includes("怕黑"))) { + throw new Error("一般情境(未排除私密)應該能檢索到私密記憶"); + } + const privateExcluded = await get(`/memory/${GENKI_ID}/retrieve?query=${encodeURIComponent("怕黑")}&excludePrivate=true`); + if (privateExcluded.results.some((m) => m.content.includes("怕黑"))) { + throw new Error("群聊情境(excludePrivate=true)不應該檢索到私密記憶"); + } + // 口風不緊(天然呆)是性格設定上的例外,資料層需標記這個豁免旗標。 + const naturalArchetype = await get("/personality/archetypes/天然呆"); + if (!naturalArchetype.traits.includes("loose-lipped")) { + throw new Error("天然呆原型應標記 loose-lipped,作為隱私邊界的性格設定例外"); + } + + // K-5 群聊記憶投影:同一場對話,被點名/被虧的那位應該記得比較牢(權重較高),旁觀者權重較低但仍留下記錄。 + const projectionRoom = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID, COOL_ID], mode: "GROUP" }); + await post(`/room/${projectionRoom.id}/message`, { + userId: USER_ID, + userLabel: "小明", + text: "哈哈笨蛋小元你今天怎麼那麼笨啊", + }); + await post(`/room/${projectionRoom.id}/end`, {}); + const genkiMemory = await prisma.episodicMemory.findFirst({ + where: { characterId: GENKI_ID, content: { contains: "笨蛋" } }, + }); + const coolMemory = await prisma.episodicMemory.findFirst({ + where: { characterId: COOL_ID, content: { contains: "笨蛋" } }, + }); + if (!genkiMemory || !coolMemory) { + throw new Error("群聊結束固化後,雙方都應該留下這場對話的記憶"); + } + if (!(genkiMemory.weight > coolMemory.weight)) { + throw new Error("被虧的那位(小元)記憶權重應該高於旁觀者(小冷)"); + } + + // K-6 角色自聊:話題種子驅動、發言權機制沿用、空轉偵測與輪數上限確保一定會收斂(不會無限跑下去)。 + const selfChatRoom = await post("/room", { + userId: USER_ID, + characterIds: [GENKI_ID, TSUNDERE_ID, COOL_ID], + mode: "SELF_CHAT", + topicSeed: "昨天放學路上的事", + }); + let ended = false; + let turns = 0; + const SAFETY_CAP = 30; // 遠大於 SELF_CHAT_MAX_TURNS,純粹防止測試本身無限迴圈 + for (let i = 0; i < SAFETY_CAP && !ended; i++) { + const result = await post(`/room/${selfChatRoom.id}/self-chat/advance`, {}); + if (result.ended) { + ended = true; + } else { + turns += 1; + } + } + if (!ended) { + throw new Error("角色自聊必須在有限輪數內收斂結束,不應該無限進行下去"); + } + if (turns === 0) { + throw new Error("角色自聊應該至少產生過一輪對話"); + } + + // 使用者插話即切換為群聊模式。 + const interjectRoom = await post("/room", { + userId: USER_ID, + characterIds: [GENKI_ID, TSUNDERE_ID], + mode: "SELF_CHAT", + topicSeed: "測試話題", + }); + await post(`/room/${interjectRoom.id}/self-chat/advance`, {}); + const beforeInterject = await get(`/room/${interjectRoom.id}`); + if (beforeInterject.mode !== "SELF_CHAT") { + throw new Error("插話前應仍為角色自聊模式"); + } + await post(`/room/${interjectRoom.id}/interject`, { userId: USER_ID, userLabel: "小明", text: "我插句話!" }); + const afterInterject = await get(`/room/${interjectRoom.id}`); + if (afterInterject.mode !== "GROUP") { + throw new Error("使用者插話後應該切換為群聊模式"); + } + + // 清理本次測試建立的角色(cascade 會一併清掉關係、記憶等)。 + await prisma.character.deleteMany({ where: { id: { in: [GENKI_ID, TSUNDERE_ID, COOL_ID] } } }); +} diff --git a/todo.md b/todo.md index 0671f21..1e7ce7e 100644 --- a/todo.md +++ b/todo.md @@ -286,13 +286,24 @@ flowchart TB ### K. 對話模式:群聊與角色自聊 -- [ ] **K-1 在場者模型(S)**:session 支援多角色參與者名冊,角色可讀取「誰在場」。驗收:群聊 session 內每個角色都能取得完整在場名單。依據:§群聊中的行為變化「在場者感知」。 -- [ ] **K-2 群聊行為變化(S)**:人前矜持(一對一會撒嬌的角色群聊時收斂、傲嬌更嘴硬)、對不同對象使用不同稱呼與語氣。驗收:同角色在一對一與群聊的同一情境輸出不同。依據:§群聊中的行為變化。 -- [ ] **K-3 隱私邊界(S)**:一對一聊過的私密內容,該角色在群聊中不主動洩漏(口風不緊為角色設定的例外)。驗收:標記為私密的記憶不會出現在群聊回應中。依據:§跨模式的記憶連續性「隱私邊界」。 -- [ ] **K-4 發言權分配(M)**:每輪計算各角色發言衝動值(被點名/話題相關度/性格基線/情緒狀態/與發言者關係/發言冷卻),超過門檻才發言;沉默也是演出。驗收:三無角色整場只發言少數次、元氣角色發言最多、剛發言者衝動下降。依據:§發言權分配表。 -- [ ] **K-5 群聊記憶投影(M)**:群聊記錄為一份共用場景記錄,session 結束時各角色以自身視角萃取記憶,情緒標記可不同。驗收:同一場群聊固化後,兩角色的情節記憶內容與權重不同。依據:§群聊的記憶投影。 -- [ ] **K-6 角色自聊(M)**:話題種子(使用者指定/共同記憶抽取/日常情境模板)、發言權沿用群聊機制、空轉偵測(重複與資訊量下降)注入轉折或收尾、輪數上限硬停損、使用者插話即切換群聊。驗收:無人插話時能自然收尾且不超過輪數上限。依據:§模式三:角色自聊(旁觀模式)。 -- [ ] **K-V 階段驗證(XS)**:`npm run restart && npm run smoke -- K`(K.mjs:發言權分佈、群聊投影差異、隱私邊界、自聊收斂)。 +- [x] **K-1 在場者模型(S)**:session 支援多角色參與者名冊,角色可讀取「誰在場」。驗收:群聊 session 內每個角色都能取得完整在場名單。依據:§群聊中的行為變化「在場者感知」。 +- [x] **K-2 群聊行為變化(S)**:人前矜持(一對一會撒嬌的角色群聊時收斂、傲嬌更嘴硬)、對不同對象使用不同稱呼與語氣。驗收:同角色在一對一與群聊的同一情境輸出不同。依據:§群聊中的行為變化。 +- [x] **K-3 隱私邊界(S)**:一對一聊過的私密內容,該角色在群聊中不主動洩漏(口風不緊為角色設定的例外)。驗收:標記為私密的記憶不會出現在群聊回應中。依據:§跨模式的記憶連續性「隱私邊界」。 +- [x] **K-4 發言權分配(M)**:每輪計算各角色發言衝動值(被點名/話題相關度/性格基線/情緒狀態/與發言者關係/發言冷卻),超過門檻才發言;沉默也是演出。驗收:三無角色整場只發言少數次、元氣角色發言最多、剛發言者衝動下降。依據:§發言權分配表。 +- [x] **K-5 群聊記憶投影(M)**:群聊記錄為一份共用場景記錄,session 結束時各角色以自身視角萃取記憶,情緒標記可不同。驗收:同一場群聊固化後,兩角色的情節記憶內容與權重不同。依據:§群聊的記憶投影。 +- [x] **K-6 角色自聊(M)**:話題種子(使用者指定/共同記憶抽取/日常情境模板)、發言權沿用群聊機制、空轉偵測(重複與資訊量下降)注入轉折或收尾、輪數上限硬停損、使用者插話即切換群聊。驗收:無人插話時能自然收尾且不超過輪數上限。依據:§模式三:角色自聊(旁觀模式)。 +- [x] **K-V 階段驗證(XS)**:`npm run restart && npm run smoke -- K`(K.mjs:在場名單、發言權分配、群聊矜持、隱私邊界、記憶投影、角色自聊收斂)。 + +> **實作記錄(K 群組)**: +> - 群聊/自聊子系統放在 `apps/api/src/room/`。**刻意不建 `Room`/`RoomTurn` 之類的資料表**:`RoomService` 用純記憶體 `Map`(同 C-1 `WorkingMemoryService` 的設計哲學)存參與者名冊與場景逐輪記錄,因為這本質上是 session 期間的暫存場景,session 結束後就該由 K-5 的固化流程萃取成各角色的長期記憶並丟棄原始記錄——長期保存的只有固化後的 `EpisodicMemory`。**這意味著 api 行程重啟會讓所有進行中的房間消失**,跟 C-1 的工作記憶是同一個已知取捨,不是 bug。 +> - 新增了 `CharacterRelationship` 資料表(有向邊:`characterId` 對 `otherCharacterId` 的觀感,`affinity` 0~100 + `dynamic` 描述性標籤),供「角色間關係上場」與 K-4 發言權分配裡「與發言者的關係」使用;未設定時預設中性值 50。這是全新的角色對角色關係系統,跟 E 群組的 `Relationship`(角色對使用者)是兩張獨立的表,不要混用。 +> - `EpisodicMemory` 新增 `isPrivate` 欄位(K-3),`packages/shared` 的 `EpisodicMemory` 型別也要同步加這個欄位(否則 apps/api 引用共享型別時型別會不匹配)。`KeywordMemoryRetriever.retrieve` 新增 `excludePrivate` 選項;**誰來決定要不要排除私密記憶是呼叫端的責任**——`RoomGenerationService` 一律傳 `excludePrivate: !archetypeParams.traits.includes("loose-lipped")`,一對一對話(`ContextAssemblerService`)完全沒動、不會受影響。`天然呆` 原型加了 `loose-lipped` 特徵旗標作為「口風不緊」的例外原型。 +> - **K-4 發言權分配公式**:`score = 性格基線×0.3 + 話題相關度×0.25 + 情緒修正×0.15 + 關係修正×0.15 + 冷落累積加成 − 剛發言冷卻懲罰`,被點名時再加一個很大的固定加成(0.6,幾乎必回),門檻設在 0.25。**話題相關度刻意不是單純的「命中詞數/清單總詞數」比例**:角色喜好清單通常只有幾個詞,一旦命中就該是強訊號,用比例會被清單長度稀釋掉,所以命中時下限給 0.5(`Math.max(0.5, hits/keywords.length)`)。中文以單字成詞很常見(貓/狗/書),關鍵字清單的切詞正規表達式必須把全角「:」「;」都當分隔符,且長度門檻不能設 `>=2`(否則會濾掉單字關鍵字)——**這是本群組踩到的實際 bug,切詞沒處理全角標點導致話題相關度永遠算不出命中**,已修正並在 `speaking-right.service.ts` 留了註解說明。 +> - **K-2 群聊矜持的實作方式很輕巧**:完全重用 G-3 的親密度分層模板系統(`intimacyTier`/`pickTemplates`),群聊/自聊時把「餵給生成上下文的親密度」下修 35(傲嬌再多扣 15),讓同一套模板庫自然选到「低親密度」那一層、更收斂的回應,**沒有另外做一套群聊專用模板**。副作用:`MockProvider` 目前完全不吃 `context.history`/`context.retrievedMemories`,所以「對不同對象不同稱呼」是在 `RoomGenerationService` 外面手動 prepend 稱呼字串(`"${address},${生成文字}"`),不是模板系統原生支援對象切換——這也是為什麼 K-3 的隱私過濾沒辦法用「回應文字裡有沒有出現私密內容」來驗收(MockProvider 根本不會把記憶內容寫進輸出),K.mjs 改成直接打 `GET /memory/:characterId/retrieve?excludePrivate=` 驗證過濾機制本身。**日後 R-3 換上真正的 LLM Provider 後,這裡的稱呼/矜持處理方式可能需要重新設計**(真正的 LLM 應該能在 prompt 裡吃到「這是群聊,在場者有誰」而自然產生矜持與對象切換,不必再靠外部下修親密度這個折衷做法)。 +> - **K-5 記憶投影的「被虧的記得比較牢」**:固化時逐輪呼叫 D-1 的 `RuleBasedEmotionTagger` 幫每一句話打情緒標記,非自己說的話只有「情緒強烈」或「提到自己(別名比對)」才留存,權重公式 `aboutMe ? 1.5+intensity : isSelf ? 1+intensity : 0.5+intensity`——同一句被虧的台詞,當事人權重 2.2、單純旁觀的角色權重只有 1.2(已用 smoke test 驗證這個差距一定成立)。 +> - **K-6 角色自聊的收斂機制**:完全重用 K-4 的 `SpeakingRightService.decideSpeakers`(把上一輪發言內容當作「發言者」丟進去算下一輪各角色的衝動值,衝動最高者發言;若全部低於門檻仍強制選最高分者發言,否則自聊會卡死不動),空轉偵測比對最近 3 轮内容是否完全重複或字數持續遞減,偵測到就注入 `TOPIC_TWIST_LINES` 轉折句;同一房間累積 2 次轉折仍空轉就直接收尾(`SELF_CHAT_WRAP_UP_LINES`)。**因為 MockProvider 的模板池很小(多數原型只有通用預設兩句),空轉偵測在測試裡幾乎每次都會被觸發**,這是預期行為,不是 bug——等 R-3 真正的 LLM 上線後,重複發生的機率會大幅降低,但空轉偵測機制本身仍應保留(真人對話一樣會陷入互相客套的迴圈)。使用者插話(`interject`)只是把 `room.mode` 從 `SELF_CHAT` 切成 `GROUP`,插話內容走跟一般群聊訊息完全相同的路徑。 +> - `RoomModule` 依賴 `LlmModule` 取得 `LLM_PROVIDER`(重用 F-2 的 Provider 抽象),但 `LlmModule` 原本只 export `DialogueService`/`BehaviorReinforcementService`,**這次補上 `LLM_PROVIDER` 到 export 清單**,否則 `RoomGenerationService` 在 DI 階段會解析不到這個 token 而直接炸掉啟動。之後任何模組想直接注入 `LLM_PROVIDER`(不透過 `DialogueService`)都要記得先確認它在 exports 裡。 +> - **`DialogueService`(一對一聊天)完全沒有被本群組改動**——群聊/自聊是平行的一套生成路徑(`RoomGenerationService`),不是在 `DialogueService` 裡加分支。這是刻意的取捨:一對一的管線已經很長(作息/破例/記憶檢索/禁則),硬塞群聊語意進去會讓兩種模式互相拖累;缺點是兩套生成路徑目前有一些邏輯重複(組裝 `GenerationContext` 的細節),**如果之後要讓兩套路徑共用更多邏輯,可以考慮把 `ContextAssemblerService` 抽出一個「不含 session/schedule 依賴」的核心組裝函式,供两邊共用**,本群組沒有做這個重構。 ### L. 戀愛關係軸與內容尺度