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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 15:15:00 +08:00

211 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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] } } });
}