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:
co-authored by
Claude Sonnet 5
parent
3503cc0be6
commit
6125d6e27f
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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] } } });
|
||||||
|
}
|
||||||
@@ -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. 戀愛關係軸與內容尺度
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user