import { prisma } from "@kokorone/db"; const API_PORT = process.env.PORT_API ?? "3001"; const WORK_ID = "smoke-m-work"; const A_ID = "smoke-m-a"; // 小明 const B_ID = "smoke-m-b"; // 小美,慣用口癖「喵」 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) { await post("/personality/characters", { id, source: "EXISTING_WORK", workId: WORK_ID, buildStatus: "CANDIDATE", formalName, basicInfo: "測試角色", backgroundStory: "測試", personalityArchetype: "元氣", likesDislikes: "測試", goalsObsessions: "測試", speechStyle: "第一人稱「我」", }); } export default async function smokeM() { await prisma.character.deleteMany({ where: { id: { in: [A_ID, B_ID] } } }); await prisma.work.deleteMany({ where: { id: WORK_ID } }); // 錨定點先設在很後面,讓早期場景一匯入就直接投影,方便測試 M-1~M-8;M-9 的知識邊界另外用低錨定點測試。 await prisma.work.create({ data: { id: WORK_ID, title: "測試作品(自製文本,非受版權原作)", anchorStoryOrder: 100 } }); await createCharacter(A_ID, "小明"); await createCharacter(B_ID, "小美"); // M-3 章節匯入與場景切分:以自製測試文本匯入場景。 const scene1 = await post("/canon/scenes", { workId: WORK_ID, volume: 1, chapterLabel: "第一章", storyOrder: 1, summary: "小明與小美在教室裡聊天。", rawText: "(自製測試文本)", participantCharacterIds: [A_ID, B_ID], lines: [ { lineType: "DIALOGUE", rawText: "早安,小美。", hasExplicitMarker: true, markerCharacterId: A_ID }, { lineType: "DIALOGUE", rawText: "早安喵!今天也要一起加油喵!", hasExplicitMarker: true, markerCharacterId: B_ID }, { lineType: "DIALOGUE", rawText: "妳今天心情不錯呢。", hasExplicitMarker: true, markerCharacterId: A_ID }, { lineType: "DIALOGUE", rawText: "當然喵,因為要跟你一起上學喵。", hasExplicitMarker: true, markerCharacterId: B_ID }, { lineType: "MONOLOGUE", rawText: "(其實我有點緊張……)", hasExplicitMarker: true, markerCharacterId: B_ID }, { lineType: "NARRATION", rawText: "教室的窗外開始下起了小雨。" }, ], interactions: [ { characterId: A_ID, otherCharacterId: B_ID, delta: 3, description: "一起上學" }, { characterId: B_ID, otherCharacterId: A_ID, delta: 5, description: "他陪我一起上學" }, ], }); // 驗收:可列出場景與在場者。 const participantIds = scene1.participants.map((p) => p.characterId).sort(); if (JSON.stringify(participantIds) !== JSON.stringify([A_ID, B_ID].sort())) { throw new Error("匯入後應該可以列出場景的在場者"); } // M-5:明示標記為決定性證據,應直接確認歸屬。 const markedLine = scene1.lines.find((l) => l.rawText === "早安,小美。"); if (markedLine.status !== "CONFIRMED" || markedLine.speakerCharacterId !== A_ID || markedLine.confidence !== 1) { throw new Error("有明示標記的台詞應該直接以信心 1 確認歸屬"); } // M-4:五類產物各自入庫且可追溯到來源場景。 const eventMemoryA = await prisma.episodicMemory.findFirst({ where: { characterId: A_ID, sourceSceneId: scene1.id } }); const eventMemoryB = await prisma.episodicMemory.findFirst({ where: { characterId: B_ID, sourceSceneId: scene1.id } }); if (!eventMemoryA || !eventMemoryB) { throw new Error("在場角色都應該從場景投影出情節記憶,且可追溯到來源場景"); } const corpusB = await prisma.languageCorpusEntry.findMany({ where: { characterId: B_ID, sceneId: scene1.id } }); if (corpusB.length !== 2) { throw new Error("小美的兩句確認歸屬台詞應該寫入語言風格語料"); } const snapshotB = await prisma.personalityTraitSnapshot.findFirst({ where: { characterId: B_ID, sourceSceneId: scene1.id } }); if (!snapshotB || !snapshotB.note.includes("緊張")) { throw new Error("內心獨白應該寫入性格參數證據並可追溯到來源場景"); } const relationshipLogA = await prisma.characterRelationshipEventLog.findFirst({ where: { characterId: A_ID, otherCharacterId: B_ID, sceneId: scene1.id } }); const relationshipLogB = await prisma.characterRelationshipEventLog.findFirst({ where: { characterId: B_ID, otherCharacterId: A_ID, sceneId: scene1.id } }); if (!relationshipLogA || !relationshipLogB || relationshipLogA.delta === relationshipLogB.delta) { throw new Error("互動應該寫入雙方視角的關係帳本,且權重可以不對稱"); } const semanticA = await prisma.semanticMemory.findFirst({ where: { characterId: A_ID, sourceSceneId: scene1.id } }); if (!semanticA || !semanticA.fact.includes("小雨")) { throw new Error("新登場設定(敘述)應該投影為在場角色的語意記憶"); } // M-6 隔離區與指紋冷啟動:小美的指紋樣本還不足 3 則時,含特徵詞的無標記台詞仍應中性看待(不足以確認)。 const scene2 = await post("/canon/scenes", { workId: WORK_ID, volume: 1, chapterLabel: "第二章", storyOrder: 2, summary: "小明與小美走去上學。", rawText: "(自製測試文本)", participantCharacterIds: [A_ID, B_ID], lines: [ { lineType: "DIALOGUE", rawText: "欸,等等我啦!" }, { lineType: "DIALOGUE", rawText: "抱歉抱歉,我走比較快了喵!" }, ], }); const stillAmbiguous = scene2.lines.every((l) => l.status === "QUARANTINED"); if (!stillAmbiguous) { throw new Error("指紋樣本不足時,即使含特徵詞也不該貿然確認歸屬——應該隔離"); } // 補齊小美的指紋樣本到 3 則以上。 await post("/canon/scenes", { workId: WORK_ID, volume: 1, chapterLabel: "第三章", storyOrder: 3, summary: "小美在教室裡說話。", rawText: "(自製測試文本)", participantCharacterIds: [B_ID], lines: [{ lineType: "DIALOGUE", rawText: "今天的便當也很好吃喵!", hasExplicitMarker: true, markerCharacterId: B_ID }], }); // M-6 驗收:指紋精煉後回頭重新嘗試,含特徵詞的那句應該被釋放入庫;完全沒有訊號的那句仍應維持隔離。 const reattempt = await post(`/canon/scenes/${scene2.id}/reattempt-quarantined`, {}); if (reattempt.released !== 1) { throw new Error(`指紋樣本補齊後重新嘗試,應該恰好釋放 1 句,實際為 ${reattempt.released}`); } const scene2Lines = await prisma.sceneLine.findMany({ where: { sceneId: scene2.id }, orderBy: { lineIndex: "asc" } }); if (scene2Lines[0].status !== "QUARANTINED") { throw new Error("完全沒有特徵詞訊號的台詞,重新嘗試後仍應維持隔離"); } if (scene2Lines[1].status !== "CONFIRMED" || scene2Lines[1].speakerCharacterId !== B_ID) { throw new Error("含小美特徵詞的台詞,指紋樣本足夠後應該被正確歸屬並釋放"); } // M-2 作品名冊與建置門檻:路人(樣本不足)維持候補,達門檻後自動建置。 const progressBefore = await get(`/canon/characters/${A_ID}/roster-progress`); if (progressBefore.meetsThreshold) { throw new Error("樣本量不足時不該達到建置門檻(此時應仍為候補)"); } await post("/canon/scenes", { workId: WORK_ID, volume: 1, chapterLabel: "第四章", storyOrder: 4, summary: "小明與小美繼續聊天。", rawText: "(自製測試文本)", participantCharacterIds: [A_ID, B_ID], lines: [ { lineType: "DIALOGUE", rawText: "再說一句。", hasExplicitMarker: true, markerCharacterId: A_ID }, { lineType: "DIALOGUE", rawText: "又一句喵。", hasExplicitMarker: true, markerCharacterId: B_ID }, { lineType: "DIALOGUE", rawText: "再一句。", hasExplicitMarker: true, markerCharacterId: A_ID }, { lineType: "DIALOGUE", rawText: "還有一句。", hasExplicitMarker: true, markerCharacterId: A_ID }, ], }); const charactersAfter = await prisma.character.findMany({ where: { id: { in: [A_ID, B_ID] } } }); if (charactersAfter.some((c) => c.buildStatus !== "BUILT")) { throw new Error("累積達建置門檻(出場場景數/具名台詞數/實質互動)後應該自動正式建置"); } // M-9 錨定點與知識邊界:超前於錨定點的場景先不投影;錨定點前移後才「知道」;錨定點後退則「不再知道」。 await prisma.work.update({ where: { id: WORK_ID }, data: { anchorStoryOrder: 4 } }); const futureScene = await post("/canon/scenes", { workId: WORK_ID, volume: 2, chapterLabel: "第五章(尚未抵達錨定點)", storyOrder: 5, summary: "小明發現了一個重大的祕密。", rawText: "(自製測試文本)", participantCharacterIds: [A_ID], lines: [{ lineType: "MONOLOGUE", rawText: "(原來是這樣……)", hasExplicitMarker: true, markerCharacterId: A_ID }], }); if (futureScene.projected) { throw new Error("超前於錨定點的場景不應該立刻投影"); } const secretMemoryBefore = await prisma.episodicMemory.count({ where: { characterId: A_ID, sourceSceneId: futureScene.id } }); if (secretMemoryBefore !== 0) { throw new Error("錨定點之前她不該知道後段劇情——不該有對應的情節記憶"); } await post(`/canon/works/${WORK_ID}/anchor`, { storyOrder: 5 }); const secretMemoryAfter = await prisma.episodicMemory.count({ where: { characterId: A_ID, sourceSceneId: futureScene.id } }); if (secretMemoryAfter !== 1) { throw new Error("錨定點推進到該場景之後,應該載入對應的情節記憶"); } await post(`/canon/works/${WORK_ID}/anchor`, { storyOrder: 4 }); const secretMemoryRetreated = await prisma.episodicMemory.count({ where: { characterId: A_ID, sourceSceneId: futureScene.id } }); if (secretMemoryRetreated !== 0) { throw new Error("錨定點後退後,角色不再知道後段劇情——對應的情節記憶應該被移除"); } await post(`/canon/works/${WORK_ID}/anchor`, { storyOrder: 5 }); // 推回去,後面 M-7/M-10 測試要用到這場戲 // M-7/M-10:修正一句台詞歸屬 → 重新投影,受影響角色同步更新;使用者互動記憶完全不受影響。 await prisma.episodicMemory.create({ data: { characterId: A_ID, content: "使用者跟我聊了關於考試的事", occurredAt: new Date(), emotionTag: "CALM", emotionIntensity: 0.3, source: "INTERACTION", weight: 1, }, }); const monologueLine = await prisma.sceneLine.findFirst({ where: { sceneId: futureScene.id } }); await post(`/canon/scene-lines/${monologueLine.id}/correct`, { newSpeakerCharacterId: B_ID }); const interactionMemory = await prisma.episodicMemory.findMany({ where: { characterId: A_ID, source: "INTERACTION" } }); if (interactionMemory.length !== 1 || interactionMemory[0].content !== "使用者跟我聊了關於考試的事") { throw new Error("修正場景資料庫後,使用者互動記錄必須完整保留、不可被竄改"); } const snapshotAAfterCorrection = await prisma.personalityTraitSnapshot.count({ where: { characterId: A_ID, sourceSceneId: futureScene.id } }); const snapshotBAfterCorrection = await prisma.personalityTraitSnapshot.count({ where: { characterId: B_ID, sourceSceneId: futureScene.id } }); if (snapshotAAfterCorrection !== 0 || snapshotBAfterCorrection !== 1) { throw new Error("修正歸屬後重新投影,性格參數證據應該同步從舊角色移到新角色"); } const realizationA = await prisma.episodicMemory.findFirst({ where: { characterId: A_ID, source: "CORRECTION" } }); const realizationB = await prisma.episodicMemory.findFirst({ where: { characterId: B_ID, source: "CORRECTION" } }); if (!realizationA || !realizationB) { throw new Error("已投影過的歸屬被修正時,雙方都應該留下一筆「想起來其實不是這樣」的新事件"); } // M-8 跨章節整合:故意置入的矛盾應該被偵測並標記;同來源優先序無法裁決者待人工確認,不同優先序可自動裁決。 await post("/canon/scenes", { workId: WORK_ID, volume: 1, chapterLabel: "運動會(小說)", storyOrder: 6, sourceType: "NOVEL", summary: "運動會當天。", rawText: "(自製測試文本)", participantCharacterIds: [A_ID], lines: [{ lineType: "NARRATION", rawText: "運動會當天下著雨。" }], facts: [{ eventLabel: "秋季運動會", factKey: "天氣", factValue: "雨天" }], }); await post("/canon/scenes", { workId: WORK_ID, volume: 1, chapterLabel: "運動會(小說・另一段)", storyOrder: 7, sourceType: "NOVEL", summary: "回憶運動會。", rawText: "(自製測試文本)", participantCharacterIds: [B_ID], lines: [{ lineType: "NARRATION", rawText: "那天陽光普照。" }], facts: [{ eventLabel: "秋季運動會", factKey: "天氣", factValue: "晴天" }], }); await post(`/canon/works/${WORK_ID}/detect-contradictions`, {}); const contradictions = await get(`/canon/works/${WORK_ID}/contradictions`); const weatherContradiction = contradictions.find((c) => c.eventLabel === "秋季運動會"); if (!weatherContradiction || weatherContradiction.status !== "PENDING_HUMAN_REVIEW") { throw new Error("同一優先序來源之間的矛盾應該被偵測到,且標記為待人工確認"); } await post("/canon/scenes", { workId: WORK_ID, volume: 1, chapterLabel: "生日設定(設定集)", storyOrder: 8, sourceType: "OFFICIAL_SETTEI", summary: "官方設定集記載。", rawText: "(自製測試文本)", participantCharacterIds: [A_ID], lines: [{ lineType: "NARRATION", rawText: "小明的生日是四月一日。" }], facts: [{ eventLabel: "小明生日", factKey: "日期", factValue: "四月一日" }], }); await post("/canon/scenes", { workId: WORK_ID, volume: 1, chapterLabel: "生日設定(動畫特典)", storyOrder: 9, sourceType: "ANIME_ADAPTATION", summary: "動畫特典記載。", rawText: "(自製測試文本)", participantCharacterIds: [A_ID], lines: [{ lineType: "NARRATION", rawText: "小明的生日是四月二日。" }], facts: [{ eventLabel: "小明生日", factKey: "日期", factValue: "四月二日" }], }); await post(`/canon/works/${WORK_ID}/detect-contradictions`, {}); const contradictions2 = await get(`/canon/works/${WORK_ID}/contradictions`); const birthdayContradiction = contradictions2.find((c) => c.eventLabel === "小明生日"); if (!birthdayContradiction || birthdayContradiction.status !== "RESOLVED" || !birthdayContradiction.resolution.includes("四月一日")) { throw new Error("不同優先序來源之間的矛盾應該能依裁決優先序自動裁決(小說原文 > 官方設定集 > 動畫改編)"); } // M-1 插圖索引:可由事件(場景)查到對應插圖,並衍生服裝目錄與姿勢語彙表。 await post("/canon/illustrations", { workId: WORK_ID, volume: 1, type: "BW_ILLUSTRATION", sceneId: scene1.id, description: "她驚訝地睜大眼睛,雙手抱著課本", costumeLabels: [{ characterId: B_ID, label: "制服" }], poseLabels: [{ characterId: B_ID, label: "總是抱著書" }], }); const illustrationsForScene = await get(`/canon/scenes/${scene1.id}/illustrations`); if (illustrationsForScene.length !== 1) { throw new Error("應該可以由事件(場景)查到對應插圖"); } const costumeCatalog = await get(`/canon/characters/${B_ID}/costume-catalog`); const poseVocabulary = await get(`/canon/characters/${B_ID}/pose-vocabulary`); if (costumeCatalog.length !== 1 || poseVocabulary.length !== 1) { throw new Error("插圖登錄後應該能列出衍生的服裝目錄與姿勢語彙表"); } // 清理本次測試建立的角色與作品(cascade 會一併清掉場景資料庫、記憶、關係帳本、插圖索引等)。 await prisma.character.deleteMany({ where: { id: { in: [A_ID, B_ID] } } }); await prisma.work.deleteMany({ where: { id: WORK_ID } }); }