Files
Kokorone/scripts/smoke/M.mjs
T
JefferyandClaude Sonnet 5 c66799ee7c feat: 完成 M 群組 — 既有作品考據與輕小說章節管線
共用場景資料庫(Scene/ScenePresence/SceneLine/SceneFact/SceneInteraction,
角色中立客觀記錄)與投影(ProjectionService 寫入 EpisodicMemory/
SemanticMemory/PersonalityTraitSnapshot/CharacterRelationshipEventLog,
逐一可追溯到來源場景)完全分層。歸屬信心分數(明示標記決定性、語言指紋
從已歸屬語料統計、場景名冊硬約束、對話輪替)與隔離區/指紋冷啟動(低信心
或樣本不足一律隔離,指紋精煉後可重新嘗試釋放)。多角色投影與修正(改一處
場景資料庫、reprojectScene 同步更新所有受影響角色,使用者互動記憶因為
從不帶 sourceSceneId 而結構性地不受影響,發現得晚的歸屬錯誤額外記一筆
CORRECTION 事件)。作品名冊建置門檻(達標自動建置,門檻檢查排在投影完成
之後才做,避免用到投影前的舊統計)。錨定點知識邊界(雙向:前進載入、
後退遺忘)與矛盾偵測(同優先序來源待人工確認,跨優先序可依小說原文>
官方設定集>動畫改編自動裁決)。插圖索引與衍生服裝目錄/姿勢語彙表。

刻意簡化:章節→場景切分與互動正負權重皆採結構化輸入(不做自動 NLP
場景邊界偵測與情感分析),比照 F-7 MockProvider 的做法——機制先做對,
之後有真實 NLP/LLM 能力再替換輸入來源。

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

328 lines
16 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 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 } });
}