feat: 完成 K 群組 — 對話模式:群聊與角色自聊

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-08-13 15:15:00 +08:00
co-authored by Claude Sonnet 5
parent 3503cc0be6
commit 6125d6e27f
19 changed files with 1088 additions and 11 deletions
+2
View File
@@ -10,6 +10,7 @@ import { PersonalityModule } from "./personality/personality.module.js";
import { ChatModule } from "./chat/chat.module.js"; import { ChatModule } from "./chat/chat.module.js";
import { ScheduleModule } from "./schedule/schedule.module.js"; import { ScheduleModule } from "./schedule/schedule.module.js";
import { TaskModule } from "./task/task.module.js"; import { TaskModule } from "./task/task.module.js";
import { RoomModule } from "./room/room.module.js";
@Module({ @Module({
imports: [ imports: [
@@ -23,6 +24,7 @@ import { TaskModule } from "./task/task.module.js";
ChatModule, ChatModule,
ScheduleModule, ScheduleModule,
TaskModule, TaskModule,
RoomModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })
+1 -1
View File
@@ -38,6 +38,6 @@ function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
ContextAssemblerService, ContextAssemblerService,
DialogueService, DialogueService,
], ],
exports: [DialogueService, BehaviorReinforcementService], exports: [DialogueService, BehaviorReinforcementService, LLM_PROVIDER],
}) })
export class LlmModule {} export class LlmModule {}
@@ -6,6 +6,8 @@ export interface RetrieveOptions {
limit?: number; limit?: number;
// E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。 // E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。
relatedUserId?: string; relatedUserId?: string;
// K-3 隱私邊界:群聊等有旁人在場的情境傳 true,標記為私密的記憶不會被檢索到。
excludePrivate?: boolean;
} }
export interface MemoryRetriever { export interface MemoryRetriever {
@@ -37,7 +39,9 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
async retrieve(characterId: string, query: string, options: RetrieveOptions = {}): Promise<EpisodicMemory[]> { async retrieve(characterId: string, query: string, options: RetrieveOptions = {}): Promise<EpisodicMemory[]> {
const limit = options.limit ?? DEFAULT_LIMIT; 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 now = Date.now();
const keywords = query.split(/\s+/).filter(Boolean); const keywords = query.split(/\s+/).filter(Boolean);
@@ -76,6 +80,7 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
lastRetrievedAt: new Date(now).toISOString(), lastRetrievedAt: new Date(now).toISOString(),
source: row.source, source: row.source,
weight: row.weight, weight: row.weight,
isPrivate: row.isPrivate,
})); }));
} }
} }
+2
View File
@@ -55,10 +55,12 @@ export class MemoryController {
@Query("query") query: string, @Query("query") query: string,
@Query("limit") limit?: string, @Query("limit") limit?: string,
@Query("relatedUserId") relatedUserId?: string, @Query("relatedUserId") relatedUserId?: string,
@Query("excludePrivate") excludePrivate?: string,
) { ) {
const results = await this.retriever.retrieve(characterId, query ?? "", { const results = await this.retriever.retrieve(characterId, query ?? "", {
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
relatedUserId, relatedUserId,
excludePrivate: excludePrivate === "true",
}); });
return { results }; return { results };
} }
+11 -2
View File
@@ -6,7 +6,8 @@ export interface ArchetypeParams {
expressiveness: number; // 情緒外顯度 0~1:極低代表幾乎不顯露(三無),極高代表全寫在臉上(元氣) expressiveness: number; // 情緒外顯度 0~1:極低代表幾乎不顯露(三無),極高代表全寫在臉上(元氣)
trustGrowthRate: number; // 信任成長速度倍率:套用在 E-2 正向事件的信任增幅上 trustGrowthRate: number; // 信任成長速度倍率:套用在 E-2 正向事件的信任增幅上
invertedIntimacyExpression: boolean; // 是否為傲嬌式反向表達(見 G-3) 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 = { const DEFAULT_PARAMS: ArchetypeParams = {
@@ -15,6 +16,7 @@ const DEFAULT_PARAMS: ArchetypeParams = {
trustGrowthRate: 1, trustGrowthRate: 1,
invertedIntimacyExpression: false, invertedIntimacyExpression: false,
traits: [], traits: [],
conversationBaseline: 0.4,
}; };
export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = { export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
@@ -24,6 +26,7 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.5, trustGrowthRate: 0.5,
invertedIntimacyExpression: true, invertedIntimacyExpression: true,
traits: ["denial-then-honest"], traits: ["denial-then-honest"],
conversationBaseline: 0.5,
}, },
冷淡: { 冷淡: {
emotionTriggerThreshold: 2.5, emotionTriggerThreshold: 2.5,
@@ -31,13 +34,16 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.3, trustGrowthRate: 0.3,
invertedIntimacyExpression: false, invertedIntimacyExpression: false,
traits: ["flat-affect", "short-replies"], traits: ["flat-affect", "short-replies"],
conversationBaseline: 0.15,
}, },
天然呆: { 天然呆: {
emotionTriggerThreshold: 0.7, emotionTriggerThreshold: 0.7,
expressiveness: 0.8, expressiveness: 0.8,
trustGrowthRate: 1.5, trustGrowthRate: 1.5,
invertedIntimacyExpression: false, invertedIntimacyExpression: false,
traits: ["misreads-context"], // K-3:口風不緊——群聊中偶爾會不小心把私密記憶說出來,是這個原型的性格設定例外。
traits: ["misreads-context", "loose-lipped"],
conversationBaseline: 0.6,
}, },
元氣: { 元氣: {
emotionTriggerThreshold: 0.7, emotionTriggerThreshold: 0.7,
@@ -45,6 +51,7 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 1.3, trustGrowthRate: 1.3,
invertedIntimacyExpression: false, invertedIntimacyExpression: false,
traits: ["talkative", "fast-emotion-decay"], traits: ["talkative", "fast-emotion-decay"],
conversationBaseline: 0.85,
}, },
大小姐: { 大小姐: {
emotionTriggerThreshold: 1.3, emotionTriggerThreshold: 1.3,
@@ -52,6 +59,7 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.4, trustGrowthRate: 0.4,
invertedIntimacyExpression: false, invertedIntimacyExpression: false,
traits: ["formal-address", "poor-loser"], traits: ["formal-address", "poor-loser"],
conversationBaseline: 0.55,
}, },
三無: { 三無: {
emotionTriggerThreshold: 3.5, emotionTriggerThreshold: 3.5,
@@ -59,6 +67,7 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.2, trustGrowthRate: 0.2,
invertedIntimacyExpression: false, invertedIntimacyExpression: false,
traits: ["very-short-replies", "sudden-overflow"], traits: ["very-short-replies", "sudden-overflow"],
conversationBaseline: 0.05,
}, },
}; };
@@ -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<number> {
const row = await this.prisma.client.characterRelationship.findUnique({
where: { characterId_otherCharacterId: { characterId, otherCharacterId } },
});
return row?.affinity ?? 50;
}
}
+38
View File
@@ -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<EmotionTag, number> = {
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 = ["好啦,先聊到這裡吧,晚點再聊!", "時間也差不多了,先這樣吧~"];
@@ -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<boolean> {
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<void> {
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<void> {
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}」`;
}
}
@@ -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<RoomReply> {
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 };
}
}
+113
View File
@@ -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);
}
}
+28
View File
@@ -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 {}
+129
View File
@@ -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<string, RoomParticipantState>;
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<string, Room>();
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);
}
}
+127
View File
@@ -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<string, number>();
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<SelfChatAdvanceResult> {
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<Room> {
return this.room.switchToGroup(roomId);
}
private async injectTwist(room: Room, twistCount: number, now: Date): Promise<SelfChatAdvanceResult> {
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<SelfChatAdvanceResult> {
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",
};
}
}
+117
View File
@@ -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<SpeakingImpulse> {
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<SpeakingImpulse[]> {
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);
}
}
+1
View File
@@ -13,6 +13,7 @@ export interface EpisodicMemory {
lastRetrievedAt?: string | null; lastRetrievedAt?: string | null;
source: MemorySource; source: MemorySource;
weight: number; weight: number;
isPrivate: boolean;
} }
export interface SemanticMemory { export interface SemanticMemory {
@@ -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");
+20
View File
@@ -134,10 +134,29 @@ model Character {
scheduleExceptions ScheduleExceptionLog[] scheduleExceptions ScheduleExceptionLog[]
tasks Task[] tasks Task[]
proactiveCareLogs ProactiveCareLog[] proactiveCareLogs ProactiveCareLog[]
relationshipsToOthers CharacterRelationship[] @relation("RelationshipFrom")
relationshipsFromOthers CharacterRelationship[] @relation("RelationshipTo")
@@map("characters") @@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 反差萌稀有度控制:記錄每次反差行為觸發的時間,供冷卻期判定。 // G-4 反差萌稀有度控制:記錄每次反差行為觸發的時間,供冷卻期判定。
model ContrastTriggerLog { model ContrastTriggerLog {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -210,6 +229,7 @@ model EpisodicMemory {
weight Float @default(1) // 權重(供遺忘/檢索排序使用) weight Float @default(1) // 權重(供遺忘/檢索排序使用)
relatedUserId String? // 這段記憶與哪位使用者相關(E-5 關係加權檢索用) relatedUserId String? // 這段記憶與哪位使用者相關(E-5 關係加權檢索用)
relatedUser User? @relation(fields: [relatedUserId], references: [id], onDelete: SetNull) relatedUser User? @relation(fields: [relatedUserId], references: [id], onDelete: SetNull)
isPrivate Boolean @default(false) // K-3 隱私邊界:一對一聊過的私密內容,群聊中不主動洩漏(口風不緊原型除外)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@map("episodic_memories") @@map("episodic_memories")
+210
View File
@@ -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] } } });
}
+18 -7
View File
@@ -286,13 +286,24 @@ flowchart TB
### K. 對話模式:群聊與角色自聊 ### K. 對話模式:群聊與角色自聊
- [ ] **K-1 在場者模型(S)**:session 支援多角色參與者名冊,角色可讀取「誰在場」。驗收:群聊 session 內每個角色都能取得完整在場名單。依據:§群聊中的行為變化「在場者感知」。 - [x] **K-1 在場者模型(S)**:session 支援多角色參與者名冊,角色可讀取「誰在場」。驗收:群聊 session 內每個角色都能取得完整在場名單。依據:§群聊中的行為變化「在場者感知」。
- [ ] **K-2 群聊行為變化(S)**:人前矜持(一對一會撒嬌的角色群聊時收斂、傲嬌更嘴硬)、對不同對象使用不同稱呼與語氣。驗收:同角色在一對一與群聊的同一情境輸出不同。依據:§群聊中的行為變化。 - [x] **K-2 群聊行為變化(S)**:人前矜持(一對一會撒嬌的角色群聊時收斂、傲嬌更嘴硬)、對不同對象使用不同稱呼與語氣。驗收:同角色在一對一與群聊的同一情境輸出不同。依據:§群聊中的行為變化。
- [ ] **K-3 隱私邊界(S)**:一對一聊過的私密內容,該角色在群聊中不主動洩漏(口風不緊為角色設定的例外)。驗收:標記為私密的記憶不會出現在群聊回應中。依據:§跨模式的記憶連續性「隱私邊界」。 - [x] **K-3 隱私邊界(S)**:一對一聊過的私密內容,該角色在群聊中不主動洩漏(口風不緊為角色設定的例外)。驗收:標記為私密的記憶不會出現在群聊回應中。依據:§跨模式的記憶連續性「隱私邊界」。
- [ ] **K-4 發言權分配(M)**:每輪計算各角色發言衝動值(被點名/話題相關度/性格基線/情緒狀態/與發言者關係/發言冷卻),超過門檻才發言;沉默也是演出。驗收:三無角色整場只發言少數次、元氣角色發言最多、剛發言者衝動下降。依據:§發言權分配表。 - [x] **K-4 發言權分配(M)**:每輪計算各角色發言衝動值(被點名/話題相關度/性格基線/情緒狀態/與發言者關係/發言冷卻),超過門檻才發言;沉默也是演出。驗收:三無角色整場只發言少數次、元氣角色發言最多、剛發言者衝動下降。依據:§發言權分配表。
- [ ] **K-5 群聊記憶投影(M)**:群聊記錄為一份共用場景記錄,session 結束時各角色以自身視角萃取記憶,情緒標記可不同。驗收:同一場群聊固化後,兩角色的情節記憶內容與權重不同。依據:§群聊的記憶投影。 - [x] **K-5 群聊記憶投影(M)**:群聊記錄為一份共用場景記錄,session 結束時各角色以自身視角萃取記憶,情緒標記可不同。驗收:同一場群聊固化後,兩角色的情節記憶內容與權重不同。依據:§群聊的記憶投影。
- [ ] **K-6 角色自聊(M)**:話題種子(使用者指定/共同記憶抽取/日常情境模板)、發言權沿用群聊機制、空轉偵測(重複與資訊量下降)注入轉折或收尾、輪數上限硬停損、使用者插話即切換群聊。驗收:無人插話時能自然收尾且不超過輪數上限。依據:§模式三:角色自聊(旁觀模式)。 - [x] **K-6 角色自聊(M)**:話題種子(使用者指定/共同記憶抽取/日常情境模板)、發言權沿用群聊機制、空轉偵測(重複與資訊量下降)注入轉折或收尾、輪數上限硬停損、使用者插話即切換群聊。驗收:無人插話時能自然收尾且不超過輪數上限。依據:§模式三:角色自聊(旁觀模式)。
- [ ] **K-V 階段驗證(XS)**:`npm run restart && npm run smoke -- K`(K.mjs:發言權分佈、群聊投影差異、隱私邊界、自聊收斂)。 - [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. 戀愛關係軸與內容尺度 ### L. 戀愛關係軸與內容尺度