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