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>
This commit is contained in:
Jeffery
2026-08-13 16:58:04 +08:00
co-authored by Claude Sonnet 5
parent cd948f0026
commit c66799ee7c
21 changed files with 1747 additions and 20 deletions
+327
View File
@@ -0,0 +1,327 @@
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 } });
}