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] } } }); }