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 { ScheduleModule } from "./schedule/schedule.module.js";
|
||||
import { TaskModule } from "./task/task.module.js";
|
||||
import { RoomModule } from "./room/room.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -23,6 +24,7 @@ import { TaskModule } from "./task/task.module.js";
|
||||
ChatModule,
|
||||
ScheduleModule,
|
||||
TaskModule,
|
||||
RoomModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@@ -38,6 +38,6 @@ function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
|
||||
ContextAssemblerService,
|
||||
DialogueService,
|
||||
],
|
||||
exports: [DialogueService, BehaviorReinforcementService],
|
||||
exports: [DialogueService, BehaviorReinforcementService, LLM_PROVIDER],
|
||||
})
|
||||
export class LlmModule {}
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface RetrieveOptions {
|
||||
limit?: number;
|
||||
// E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。
|
||||
relatedUserId?: string;
|
||||
// K-3 隱私邊界:群聊等有旁人在場的情境傳 true,標記為私密的記憶不會被檢索到。
|
||||
excludePrivate?: boolean;
|
||||
}
|
||||
|
||||
export interface MemoryRetriever {
|
||||
@@ -37,7 +39,9 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
|
||||
|
||||
async retrieve(characterId: string, query: string, options: RetrieveOptions = {}): Promise<EpisodicMemory[]> {
|
||||
const limit = options.limit ?? DEFAULT_LIMIT;
|
||||
const rows = await this.prisma.client.episodicMemory.findMany({ where: { characterId } });
|
||||
const rows = await this.prisma.client.episodicMemory.findMany({
|
||||
where: { characterId, ...(options.excludePrivate ? { isPrivate: false } : {}) },
|
||||
});
|
||||
const now = Date.now();
|
||||
const keywords = query.split(/\s+/).filter(Boolean);
|
||||
|
||||
@@ -76,6 +80,7 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
|
||||
lastRetrievedAt: new Date(now).toISOString(),
|
||||
source: row.source,
|
||||
weight: row.weight,
|
||||
isPrivate: row.isPrivate,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,12 @@ export class MemoryController {
|
||||
@Query("query") query: string,
|
||||
@Query("limit") limit?: string,
|
||||
@Query("relatedUserId") relatedUserId?: string,
|
||||
@Query("excludePrivate") excludePrivate?: string,
|
||||
) {
|
||||
const results = await this.retriever.retrieve(characterId, query ?? "", {
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
relatedUserId,
|
||||
excludePrivate: excludePrivate === "true",
|
||||
});
|
||||
return { results };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ export interface ArchetypeParams {
|
||||
expressiveness: number; // 情緒外顯度 0~1:極低代表幾乎不顯露(三無),極高代表全寫在臉上(元氣)
|
||||
trustGrowthRate: number; // 信任成長速度倍率:套用在 E-2 正向事件的信任增幅上
|
||||
invertedIntimacyExpression: boolean; // 是否為傲嬌式反向表達(見 G-3)
|
||||
traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6)參考
|
||||
traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6/K-3)參考
|
||||
conversationBaseline: number; // K-4 群聊發言基線 0~1:不被點名、無特別理由時主動開口的傾向(元氣高、三無極低)
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS: ArchetypeParams = {
|
||||
@@ -15,6 +16,7 @@ const DEFAULT_PARAMS: ArchetypeParams = {
|
||||
trustGrowthRate: 1,
|
||||
invertedIntimacyExpression: false,
|
||||
traits: [],
|
||||
conversationBaseline: 0.4,
|
||||
};
|
||||
|
||||
export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
@@ -24,6 +26,7 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
trustGrowthRate: 0.5,
|
||||
invertedIntimacyExpression: true,
|
||||
traits: ["denial-then-honest"],
|
||||
conversationBaseline: 0.5,
|
||||
},
|
||||
冷淡: {
|
||||
emotionTriggerThreshold: 2.5,
|
||||
@@ -31,13 +34,16 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
trustGrowthRate: 0.3,
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["flat-affect", "short-replies"],
|
||||
conversationBaseline: 0.15,
|
||||
},
|
||||
天然呆: {
|
||||
emotionTriggerThreshold: 0.7,
|
||||
expressiveness: 0.8,
|
||||
trustGrowthRate: 1.5,
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["misreads-context"],
|
||||
// K-3:口風不緊——群聊中偶爾會不小心把私密記憶說出來,是這個原型的性格設定例外。
|
||||
traits: ["misreads-context", "loose-lipped"],
|
||||
conversationBaseline: 0.6,
|
||||
},
|
||||
元氣: {
|
||||
emotionTriggerThreshold: 0.7,
|
||||
@@ -45,6 +51,7 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
trustGrowthRate: 1.3,
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["talkative", "fast-emotion-decay"],
|
||||
conversationBaseline: 0.85,
|
||||
},
|
||||
大小姐: {
|
||||
emotionTriggerThreshold: 1.3,
|
||||
@@ -52,6 +59,7 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
trustGrowthRate: 0.4,
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["formal-address", "poor-loser"],
|
||||
conversationBaseline: 0.55,
|
||||
},
|
||||
三無: {
|
||||
emotionTriggerThreshold: 3.5,
|
||||
@@ -59,6 +67,7 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
trustGrowthRate: 0.2,
|
||||
invertedIntimacyExpression: false,
|
||||
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;
|
||||
source: MemorySource;
|
||||
weight: number;
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
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[]
|
||||
tasks Task[]
|
||||
proactiveCareLogs ProactiveCareLog[]
|
||||
relationshipsToOthers CharacterRelationship[] @relation("RelationshipFrom")
|
||||
relationshipsFromOthers CharacterRelationship[] @relation("RelationshipTo")
|
||||
|
||||
@@map("characters")
|
||||
}
|
||||
|
||||
// K-2/K-4 角色間關係:群聊中「角色間關係上場」與發言權分配的「與發言者的關係」都吃這張表。
|
||||
// 有向邊(characterId 對 otherCharacterId 的觀感),故同作品兩角色互相的觀感可以不對稱。
|
||||
model CharacterRelationship {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
character Character @relation("RelationshipFrom", fields: [characterId], references: [id], onDelete: Cascade)
|
||||
otherCharacterId String
|
||||
otherCharacter Character @relation("RelationshipTo", fields: [otherCharacterId], references: [id], onDelete: Cascade)
|
||||
affinity Int @default(50) // 0~100,越高越要好;50 為中性
|
||||
dynamic String? // 互動基調描述性標籤,例如 loyal-friend/rival/protective/crush
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([characterId, otherCharacterId])
|
||||
@@map("character_relationships")
|
||||
}
|
||||
|
||||
// G-4 反差萌稀有度控制:記錄每次反差行為觸發的時間,供冷卻期判定。
|
||||
model ContrastTriggerLog {
|
||||
id String @id @default(cuid())
|
||||
@@ -210,6 +229,7 @@ model EpisodicMemory {
|
||||
weight Float @default(1) // 權重(供遺忘/檢索排序使用)
|
||||
relatedUserId String? // 這段記憶與哪位使用者相關(E-5 關係加權檢索用)
|
||||
relatedUser User? @relation(fields: [relatedUserId], references: [id], onDelete: SetNull)
|
||||
isPrivate Boolean @default(false) // K-3 隱私邊界:一對一聊過的私密內容,群聊中不主動洩漏(口風不緊原型除外)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("episodic_memories")
|
||||
|
||||
@@ -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-1 在場者模型(S)**:session 支援多角色參與者名冊,角色可讀取「誰在場」。驗收:群聊 session 內每個角色都能取得完整在場名單。依據:§群聊中的行為變化「在場者感知」。
|
||||
- [ ] **K-2 群聊行為變化(S)**:人前矜持(一對一會撒嬌的角色群聊時收斂、傲嬌更嘴硬)、對不同對象使用不同稱呼與語氣。驗收:同角色在一對一與群聊的同一情境輸出不同。依據:§群聊中的行為變化。
|
||||
- [ ] **K-3 隱私邊界(S)**:一對一聊過的私密內容,該角色在群聊中不主動洩漏(口風不緊為角色設定的例外)。驗收:標記為私密的記憶不會出現在群聊回應中。依據:§跨模式的記憶連續性「隱私邊界」。
|
||||
- [ ] **K-4 發言權分配(M)**:每輪計算各角色發言衝動值(被點名/話題相關度/性格基線/情緒狀態/與發言者關係/發言冷卻),超過門檻才發言;沉默也是演出。驗收:三無角色整場只發言少數次、元氣角色發言最多、剛發言者衝動下降。依據:§發言權分配表。
|
||||
- [ ] **K-5 群聊記憶投影(M)**:群聊記錄為一份共用場景記錄,session 結束時各角色以自身視角萃取記憶,情緒標記可不同。驗收:同一場群聊固化後,兩角色的情節記憶內容與權重不同。依據:§群聊的記憶投影。
|
||||
- [ ] **K-6 角色自聊(M)**:話題種子(使用者指定/共同記憶抽取/日常情境模板)、發言權沿用群聊機制、空轉偵測(重複與資訊量下降)注入轉折或收尾、輪數上限硬停損、使用者插話即切換群聊。驗收:無人插話時能自然收尾且不超過輪數上限。依據:§模式三:角色自聊(旁觀模式)。
|
||||
- [ ] **K-V 階段驗證(XS)**:`npm run restart && npm run smoke -- K`(K.mjs:發言權分佈、群聊投影差異、隱私邊界、自聊收斂)。
|
||||
- [x] **K-1 在場者模型(S)**:session 支援多角色參與者名冊,角色可讀取「誰在場」。驗收:群聊 session 內每個角色都能取得完整在場名單。依據:§群聊中的行為變化「在場者感知」。
|
||||
- [x] **K-2 群聊行為變化(S)**:人前矜持(一對一會撒嬌的角色群聊時收斂、傲嬌更嘴硬)、對不同對象使用不同稱呼與語氣。驗收:同角色在一對一與群聊的同一情境輸出不同。依據:§群聊中的行為變化。
|
||||
- [x] **K-3 隱私邊界(S)**:一對一聊過的私密內容,該角色在群聊中不主動洩漏(口風不緊為角色設定的例外)。驗收:標記為私密的記憶不會出現在群聊回應中。依據:§跨模式的記憶連續性「隱私邊界」。
|
||||
- [x] **K-4 發言權分配(M)**:每輪計算各角色發言衝動值(被點名/話題相關度/性格基線/情緒狀態/與發言者關係/發言冷卻),超過門檻才發言;沉默也是演出。驗收:三無角色整場只發言少數次、元氣角色發言最多、剛發言者衝動下降。依據:§發言權分配表。
|
||||
- [x] **K-5 群聊記憶投影(M)**:群聊記錄為一份共用場景記錄,session 結束時各角色以自身視角萃取記憶,情緒標記可不同。驗收:同一場群聊固化後,兩角色的情節記憶內容與權重不同。依據:§群聊的記憶投影。
|
||||
- [x] **K-6 角色自聊(M)**:話題種子(使用者指定/共同記憶抽取/日常情境模板)、發言權沿用群聊機制、空轉偵測(重複與資訊量下降)注入轉折或收尾、輪數上限硬停損、使用者插話即切換群聊。驗收:無人插話時能自然收尾且不超過輪數上限。依據:§模式三:角色自聊(旁觀模式)。
|
||||
- [x] **K-V 階段驗證(XS)**:`npm run restart && npm run smoke -- K`(K.mjs:在場名單、發言權分配、群聊矜持、隱私邊界、記憶投影、角色自聊收斂)。
|
||||
|
||||
> **實作記錄(K 群組)**:
|
||||
> - 群聊/自聊子系統放在 `apps/api/src/room/`。**刻意不建 `Room`/`RoomTurn` 之類的資料表**:`RoomService` 用純記憶體 `Map`(同 C-1 `WorkingMemoryService` 的設計哲學)存參與者名冊與場景逐輪記錄,因為這本質上是 session 期間的暫存場景,session 結束後就該由 K-5 的固化流程萃取成各角色的長期記憶並丟棄原始記錄——長期保存的只有固化後的 `EpisodicMemory`。**這意味著 api 行程重啟會讓所有進行中的房間消失**,跟 C-1 的工作記憶是同一個已知取捨,不是 bug。
|
||||
> - 新增了 `CharacterRelationship` 資料表(有向邊:`characterId` 對 `otherCharacterId` 的觀感,`affinity` 0~100 + `dynamic` 描述性標籤),供「角色間關係上場」與 K-4 發言權分配裡「與發言者的關係」使用;未設定時預設中性值 50。這是全新的角色對角色關係系統,跟 E 群組的 `Relationship`(角色對使用者)是兩張獨立的表,不要混用。
|
||||
> - `EpisodicMemory` 新增 `isPrivate` 欄位(K-3),`packages/shared` 的 `EpisodicMemory` 型別也要同步加這個欄位(否則 apps/api 引用共享型別時型別會不匹配)。`KeywordMemoryRetriever.retrieve` 新增 `excludePrivate` 選項;**誰來決定要不要排除私密記憶是呼叫端的責任**——`RoomGenerationService` 一律傳 `excludePrivate: !archetypeParams.traits.includes("loose-lipped")`,一對一對話(`ContextAssemblerService`)完全沒動、不會受影響。`天然呆` 原型加了 `loose-lipped` 特徵旗標作為「口風不緊」的例外原型。
|
||||
> - **K-4 發言權分配公式**:`score = 性格基線×0.3 + 話題相關度×0.25 + 情緒修正×0.15 + 關係修正×0.15 + 冷落累積加成 − 剛發言冷卻懲罰`,被點名時再加一個很大的固定加成(0.6,幾乎必回),門檻設在 0.25。**話題相關度刻意不是單純的「命中詞數/清單總詞數」比例**:角色喜好清單通常只有幾個詞,一旦命中就該是強訊號,用比例會被清單長度稀釋掉,所以命中時下限給 0.5(`Math.max(0.5, hits/keywords.length)`)。中文以單字成詞很常見(貓/狗/書),關鍵字清單的切詞正規表達式必須把全角「:」「;」都當分隔符,且長度門檻不能設 `>=2`(否則會濾掉單字關鍵字)——**這是本群組踩到的實際 bug,切詞沒處理全角標點導致話題相關度永遠算不出命中**,已修正並在 `speaking-right.service.ts` 留了註解說明。
|
||||
> - **K-2 群聊矜持的實作方式很輕巧**:完全重用 G-3 的親密度分層模板系統(`intimacyTier`/`pickTemplates`),群聊/自聊時把「餵給生成上下文的親密度」下修 35(傲嬌再多扣 15),讓同一套模板庫自然选到「低親密度」那一層、更收斂的回應,**沒有另外做一套群聊專用模板**。副作用:`MockProvider` 目前完全不吃 `context.history`/`context.retrievedMemories`,所以「對不同對象不同稱呼」是在 `RoomGenerationService` 外面手動 prepend 稱呼字串(`"${address},${生成文字}"`),不是模板系統原生支援對象切換——這也是為什麼 K-3 的隱私過濾沒辦法用「回應文字裡有沒有出現私密內容」來驗收(MockProvider 根本不會把記憶內容寫進輸出),K.mjs 改成直接打 `GET /memory/:characterId/retrieve?excludePrivate=` 驗證過濾機制本身。**日後 R-3 換上真正的 LLM Provider 後,這裡的稱呼/矜持處理方式可能需要重新設計**(真正的 LLM 應該能在 prompt 裡吃到「這是群聊,在場者有誰」而自然產生矜持與對象切換,不必再靠外部下修親密度這個折衷做法)。
|
||||
> - **K-5 記憶投影的「被虧的記得比較牢」**:固化時逐輪呼叫 D-1 的 `RuleBasedEmotionTagger` 幫每一句話打情緒標記,非自己說的話只有「情緒強烈」或「提到自己(別名比對)」才留存,權重公式 `aboutMe ? 1.5+intensity : isSelf ? 1+intensity : 0.5+intensity`——同一句被虧的台詞,當事人權重 2.2、單純旁觀的角色權重只有 1.2(已用 smoke test 驗證這個差距一定成立)。
|
||||
> - **K-6 角色自聊的收斂機制**:完全重用 K-4 的 `SpeakingRightService.decideSpeakers`(把上一輪發言內容當作「發言者」丟進去算下一輪各角色的衝動值,衝動最高者發言;若全部低於門檻仍強制選最高分者發言,否則自聊會卡死不動),空轉偵測比對最近 3 轮内容是否完全重複或字數持續遞減,偵測到就注入 `TOPIC_TWIST_LINES` 轉折句;同一房間累積 2 次轉折仍空轉就直接收尾(`SELF_CHAT_WRAP_UP_LINES`)。**因為 MockProvider 的模板池很小(多數原型只有通用預設兩句),空轉偵測在測試裡幾乎每次都會被觸發**,這是預期行為,不是 bug——等 R-3 真正的 LLM 上線後,重複發生的機率會大幅降低,但空轉偵測機制本身仍應保留(真人對話一樣會陷入互相客套的迴圈)。使用者插話(`interject`)只是把 `room.mode` 從 `SELF_CHAT` 切成 `GROUP`,插話內容走跟一般群聊訊息完全相同的路徑。
|
||||
> - `RoomModule` 依賴 `LlmModule` 取得 `LLM_PROVIDER`(重用 F-2 的 Provider 抽象),但 `LlmModule` 原本只 export `DialogueService`/`BehaviorReinforcementService`,**這次補上 `LLM_PROVIDER` 到 export 清單**,否則 `RoomGenerationService` 在 DI 階段會解析不到這個 token 而直接炸掉啟動。之後任何模組想直接注入 `LLM_PROVIDER`(不透過 `DialogueService`)都要記得先確認它在 exports 裡。
|
||||
> - **`DialogueService`(一對一聊天)完全沒有被本群組改動**——群聊/自聊是平行的一套生成路徑(`RoomGenerationService`),不是在 `DialogueService` 裡加分支。這是刻意的取捨:一對一的管線已經很長(作息/破例/記憶檢索/禁則),硬塞群聊語意進去會讓兩種模式互相拖累;缺點是兩套生成路徑目前有一些邏輯重複(組裝 `GenerationContext` 的細節),**如果之後要讓兩套路徑共用更多邏輯,可以考慮把 `ContextAssemblerService` 抽出一個「不含 session/schedule 依賴」的核心組裝函式,供两邊共用**,本群組沒有做這個重構。
|
||||
|
||||
### L. 戀愛關係軸與內容尺度
|
||||
|
||||
|
||||
Reference in New Issue
Block a user