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
+2
View File
@@ -12,6 +12,7 @@ import { ScheduleModule } from "./schedule/schedule.module.js";
import { TaskModule } from "./task/task.module.js";
import { RoomModule } from "./room/room.module.js";
import { RomanceModule } from "./romance/romance.module.js";
import { CanonModule } from "./canon/canon.module.js";
@Module({
imports: [
@@ -27,6 +28,7 @@ import { RomanceModule } from "./romance/romance.module.js";
TaskModule,
RoomModule,
RomanceModule,
CanonModule,
],
controllers: [HealthController],
})
+54
View File
@@ -0,0 +1,54 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { ProjectionService } from "./projection.service.js";
import { WorkRosterService } from "./work-roster.service.js";
// M-9 錨定點與知識邊界:錨定點之前是角色的親身經歷、之後不存在。場景匯入時只寫入共用場景資料庫,
// 只有 storyOrder <= 錨定點的場景會被投影進角色記憶——移動錨定點(無論前進或後退)都要讓「已投影」的
// 場景集合與「storyOrder <= 錨定點」的場景集合重新同步。
@Injectable()
export class AnchorService {
constructor(
private readonly prisma: PrismaService,
private readonly projection: ProjectionService,
private readonly roster: WorkRosterService,
) {}
async setAnchor(workId: string, newStoryOrder: number): Promise<{ projected: number; unprojected: number }> {
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
const toProject = await this.prisma.client.scene.findMany({
where: { workId, storyOrder: { lte: newStoryOrder }, projected: false },
include: { participants: true },
});
for (const scene of toProject) {
await this.projection.projectScene(scene.id);
for (const presence of scene.participants) {
await this.roster.checkAndPromote(presence.characterId);
}
}
// 錨定點後退:她「不再知道」超出新錨定點的後段劇情——移除這些場景先前投影出的所有角色資料。
const toUnproject = await this.prisma.client.scene.findMany({
where: { workId, storyOrder: { gt: newStoryOrder }, projected: true },
});
for (const scene of toUnproject) {
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: scene.id } });
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: scene.id } });
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: scene.id } });
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId: scene.id } });
await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: false } });
}
await this.prisma.client.work.update({ where: { id: work.id }, data: { anchorStoryOrder: newStoryOrder } });
return { projected: toProject.length, unprojected: toUnproject.length };
}
// M-9 依錨定點取用「當時的她」的性格參數:取 atStoryOrder <= 錨定點裡最新的一筆快照。
async effectivePersonalitySnapshot(characterId: string, atStoryOrder: number) {
return this.prisma.client.personalityTraitSnapshot.findFirst({
where: { characterId, atStoryOrder: { lte: atStoryOrder } },
orderBy: { atStoryOrder: "desc" },
});
}
}
+137
View File
@@ -0,0 +1,137 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { FingerprintService } from "./fingerprint.service.js";
import { FINGERPRINT_WEIGHT, TURN_TAKING_WEIGHT, CONFIRM_THRESHOLD, FINGERPRINT_CONTRADICTION_SCORE } from "./constants.js";
interface CandidateScore {
characterId: string;
score: number;
fingerprintScore: number;
}
// M-5/M-6 歸屬時的多重證據與信心分數,以及隔離區機制:
// 明示標記(決定性)> 語言指紋(強)> 場景名冊(硬約束,篩掉不在場者)> 對話輪替(中)> 內容合理性(中,本實作暫略)。
// 低信心一律隔離,不寫入任何角色——寧可缺這句語料,不可污染角色資料。
@Injectable()
export class AttributionService {
constructor(
private readonly prisma: PrismaService,
private readonly fingerprint: FingerprintService,
) {}
// 冷啟動順序:先處理有明示標記的台詞(建立指紋基準),再回頭處理無標記對話。
async attributeScene(sceneId: string): Promise<void> {
const [scene, presences] = await Promise.all([
this.prisma.client.scene.findUniqueOrThrow({ where: { id: sceneId } }),
this.prisma.client.scenePresence.findMany({ where: { sceneId } }),
]);
const presentCharacterIds = presences.map((p) => p.characterId);
const lines = await this.prisma.client.sceneLine.findMany({
where: { sceneId, status: "PENDING" },
orderBy: { lineIndex: "asc" },
});
const markerLines = lines.filter((line) => line.hasExplicitMarker);
const unmarkedLines = lines.filter((line) => !line.hasExplicitMarker);
for (const line of markerLines) {
await this.confirm(line.id, line.markerCharacterId!, 1, scene.id, line.lineType);
}
for (const line of unmarkedLines) {
if (line.lineType === "NARRATION") {
// 敘述/新登場設定沒有「說話者」的歸屬問題,直接視為已確認(不需要指紋比對)。
await this.confirm(line.id, null, 1, scene.id, line.lineType);
continue;
}
await this.attributeLine(line.id, sceneId, presentCharacterIds);
}
}
private async attributeLine(lineId: string, sceneId: string, presentCharacterIds: string[]): Promise<void> {
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
if (presentCharacterIds.length === 0) {
await this.quarantine(lineId);
return;
}
const previousConfirmed = await this.prisma.client.sceneLine.findFirst({
where: { sceneId, lineIndex: { lt: line.lineIndex }, status: "CONFIRMED", speakerCharacterId: { not: null } },
orderBy: { lineIndex: "desc" },
});
const candidates: CandidateScore[] = [];
for (const characterId of presentCharacterIds) {
const fingerprintScore = await this.fingerprint.matchScore(line.rawText, characterId);
// 對話輪替:兩人場景中,上一句已確認的發言者若不是這位候選人,給予輪替加成(一問一答的結構)。
const turnTakingScore =
presentCharacterIds.length === 2 && previousConfirmed && previousConfirmed.speakerCharacterId !== characterId
? 1
: 0.5;
const score = fingerprintScore * FINGERPRINT_WEIGHT + turnTakingScore * TURN_TAKING_WEIGHT;
candidates.push({ characterId, score, fingerprintScore });
}
candidates.sort((a, b) => b.score - a.score);
const best = candidates[0];
if (best.score < CONFIRM_THRESHOLD || best.fingerprintScore < FINGERPRINT_CONTRADICTION_SCORE) {
await this.quarantine(lineId);
return;
}
await this.confirm(lineId, best.characterId, best.score, sceneId, line.lineType);
}
private async confirm(
lineId: string,
speakerCharacterId: string | null,
confidence: number,
sceneId: string,
lineType: string,
): Promise<void> {
await this.prisma.client.sceneLine.update({
where: { id: lineId },
data: { speakerCharacterId, confidence, status: "CONFIRMED" },
});
if (speakerCharacterId && lineType === "DIALOGUE") {
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
await this.prisma.client.languageCorpusEntry.create({
data: { characterId: speakerCharacterId, sceneId, text: line.rawText },
});
}
}
private async quarantine(lineId: string): Promise<void> {
await this.prisma.client.sceneLine.update({ where: { id: lineId }, data: { status: "QUARANTINED", confidence: 0 } });
}
// M-6 人工裁決:解除隔離,直接指定歸屬並寫入場景資料庫(含語料)。
async adjudicate(lineId: string, speakerCharacterId: string): Promise<void> {
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
await this.confirm(lineId, speakerCharacterId, 1, line.sceneId, line.lineType);
}
// M-6 指紋精煉:語料變多後,回頭重新嘗試隔離區裡的台詞,部分可望被釋放入庫。
async reattemptQuarantined(sceneId: string): Promise<number> {
const presences = await this.prisma.client.scenePresence.findMany({ where: { sceneId } });
const presentCharacterIds = presences.map((p) => p.characterId);
const quarantined = await this.prisma.client.sceneLine.findMany({
where: { sceneId, status: "QUARANTINED" },
orderBy: { lineIndex: "asc" },
});
let released = 0;
for (const line of quarantined) {
await this.prisma.client.sceneLine.update({ where: { id: line.id }, data: { status: "PENDING" } });
await this.attributeLine(line.id, sceneId, presentCharacterIds);
const updated = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: line.id } });
if (updated.status === "CONFIRMED") {
released += 1;
}
}
return released;
}
}
+113
View File
@@ -0,0 +1,113 @@
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import type { IllustrationType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
import { SceneImportService, type ImportSceneInput } from "./scene-import.service.js";
import { AttributionService } from "./attribution.service.js";
import { CorrectionService } from "./correction.service.js";
import { WorkRosterService } from "./work-roster.service.js";
import { IllustrationService } from "./illustration.service.js";
import { AnchorService } from "./anchor.service.js";
import { TimelineService } from "./timeline.service.js";
@Controller("canon")
export class CanonController {
constructor(
private readonly prisma: PrismaService,
private readonly sceneImport: SceneImportService,
private readonly attribution: AttributionService,
private readonly correction: CorrectionService,
private readonly roster: WorkRosterService,
private readonly illustration: IllustrationService,
private readonly anchor: AnchorService,
private readonly timeline: TimelineService,
) {}
@Post("scenes")
async importScene(@Body() body: ImportSceneInput) {
return this.sceneImport.importScene(body);
}
@Get("scenes/:sceneId")
async getScene(@Param("sceneId") sceneId: string) {
return this.prisma.client.scene.findUniqueOrThrow({
where: { id: sceneId },
include: { participants: true, lines: true, facts: true },
});
}
@Get("works/:workId/scenes")
async listScenes(@Param("workId") workId: string) {
return this.prisma.client.scene.findMany({ where: { workId }, orderBy: { storyOrder: "asc" } });
}
@Post("scene-lines/:lineId/adjudicate")
async adjudicate(@Param("lineId") lineId: string, @Body() body: { speakerCharacterId: string }) {
await this.attribution.adjudicate(lineId, body.speakerCharacterId);
return this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
}
@Post("scenes/:sceneId/reattempt-quarantined")
async reattemptQuarantined(@Param("sceneId") sceneId: string) {
const released = await this.attribution.reattemptQuarantined(sceneId);
return { released };
}
@Post("scene-lines/:lineId/correct")
async correctAttribution(@Param("lineId") lineId: string, @Body() body: { newSpeakerCharacterId: string; now?: string }) {
await this.correction.correctLineAttribution(lineId, body.newSpeakerCharacterId, body.now ? new Date(body.now) : undefined);
return this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
}
@Get("characters/:characterId/roster-progress")
async rosterProgress(@Param("characterId") characterId: string) {
return this.roster.getProgress(characterId);
}
@Post("illustrations")
async registerIllustration(
@Body()
body: {
workId: string;
volume: number;
page?: number;
type: IllustrationType;
sceneId?: string;
description: string;
costumeLabels?: { characterId: string; label: string }[];
poseLabels?: { characterId: string; label: string }[];
},
) {
return this.illustration.register(body);
}
@Get("scenes/:sceneId/illustrations")
async illustrationsForScene(@Param("sceneId") sceneId: string) {
return this.illustration.findByScene(sceneId);
}
@Get("characters/:characterId/costume-catalog")
async costumeCatalog(@Param("characterId") characterId: string) {
return this.illustration.listCostumeCatalog(characterId);
}
@Get("characters/:characterId/pose-vocabulary")
async poseVocabulary(@Param("characterId") characterId: string) {
return this.illustration.listPoseVocabulary(characterId);
}
@Post("works/:workId/anchor")
async setAnchor(@Param("workId") workId: string, @Body() body: { storyOrder: number }) {
return this.anchor.setAnchor(workId, body.storyOrder);
}
@Post("works/:workId/detect-contradictions")
async detectContradictions(@Param("workId") workId: string) {
const created = await this.timeline.detectContradictions(workId);
return { created };
}
@Get("works/:workId/contradictions")
async listContradictions(@Param("workId") workId: string) {
return this.timeline.listContradictions(workId);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { EmotionModule } from "../emotion/emotion.module.js";
import { FingerprintService } from "./fingerprint.service.js";
import { WorkRosterService } from "./work-roster.service.js";
import { IllustrationService } from "./illustration.service.js";
import { AttributionService } from "./attribution.service.js";
import { ProjectionService } from "./projection.service.js";
import { SceneImportService } from "./scene-import.service.js";
import { AnchorService } from "./anchor.service.js";
import { TimelineService } from "./timeline.service.js";
import { CorrectionService } from "./correction.service.js";
import { CanonController } from "./canon.controller.js";
@Module({
imports: [PrismaModule, EmotionModule],
controllers: [CanonController],
providers: [
FingerprintService,
WorkRosterService,
IllustrationService,
AttributionService,
ProjectionService,
SceneImportService,
AnchorService,
TimelineService,
CorrectionService,
],
exports: [
FingerprintService,
WorkRosterService,
IllustrationService,
AttributionService,
ProjectionService,
SceneImportService,
AnchorService,
TimelineService,
CorrectionService,
],
})
export class CanonModule {}
+23
View File
@@ -0,0 +1,23 @@
// M-2 建置門檻:新角色候補累積到這些條件才正式建置(路人只出現一次不值得建置)。
export const BUILD_THRESHOLD_SCENES = 3; // 出場場景數
export const BUILD_THRESHOLD_NAMED_LINES = 5; // 具名(已 CONFIRMED)台詞數
export const BUILD_THRESHOLD_INTERACTIONS = 1; // 與其他角色的實質互動(關係帳本事件)數
// M-5 歸屬信心分數:多重證據合成的權重與門檻。
export const FINGERPRINT_WEIGHT = 0.6;
export const TURN_TAKING_WEIGHT = 0.4;
export const CONFIRM_THRESHOLD = 0.55;
// M-6 指紋回檢:候選角色的指紋分數低於此值,即使總分達門檻仍視為與既有指紋矛盾,隔離不寫入。
export const FINGERPRINT_CONTRADICTION_SCORE = 0.15;
// M-5/M-6 語言指紋:學習自 LanguageCorpusEntry 的特徵詞頻率統計(人稱/語尾/口癖),非原型模板。
export const SIGNAL_TOKENS = ["呢~", "本大爺", "的說", "喵"];
// 語料達此則數以上,才認定「指紋已建立」(樣本太少時中性看待,不視為矛盾)。
export const FINGERPRINT_MIN_SAMPLES = 3;
// M-8 矛盾裁決優先序(數值越小優先權越高):小說原文 > 官方設定集 > 動畫改編。
export const SOURCE_PRIORITY: Record<string, number> = {
NOVEL: 1,
OFFICIAL_SETTEI: 2,
ANIME_ADAPTATION: 3,
};
+70
View File
@@ -0,0 +1,70 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { ProjectionService } from "./projection.service.js";
// M-7/M-10 歸屬修正與重新投影:修正場景資料庫只改一處,受影響角色的資料同步更新;
// 已上線角色發現歷史歸屬錯誤時,使用者互動記憶(source=INTERACTION)從不被觸碰——
// 只有由這個場景投影而來的資料(sourceSceneId 指到它)會被清掉重建。發現得晚的錯誤額外記一筆
// 「想起來其實不是這樣」的新事件,而不是假裝沒發生過。
@Injectable()
export class CorrectionService {
constructor(
private readonly prisma: PrismaService,
private readonly projection: ProjectionService,
) {}
async correctLineAttribution(lineId: string, newSpeakerCharacterId: string, now: Date = new Date()): Promise<void> {
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
const oldSpeakerCharacterId = line.speakerCharacterId;
if (oldSpeakerCharacterId === newSpeakerCharacterId) {
return;
}
const scene = await this.prisma.client.scene.findUniqueOrThrow({ where: { id: line.sceneId } });
const wasAlreadyProjected = scene.projected;
if (line.lineType === "DIALOGUE" && oldSpeakerCharacterId) {
await this.prisma.client.languageCorpusEntry.deleteMany({
where: { characterId: oldSpeakerCharacterId, sceneId: scene.id, text: line.rawText },
});
await this.prisma.client.languageCorpusEntry.create({
data: { characterId: newSpeakerCharacterId, sceneId: scene.id, text: line.rawText },
});
}
await this.prisma.client.sceneLine.update({
where: { id: lineId },
data: { speakerCharacterId: newSpeakerCharacterId, status: "CONFIRMED", confidence: 1 },
});
if (!wasAlreadyProjected) {
// 這場戲還沒被投影過(超前於錨定點,或當初就隔離未寫入),修正場景資料庫即可,沒有已投影的舊資料要清。
return;
}
await this.projection.reprojectScene(scene.id);
if (oldSpeakerCharacterId) {
await this.recordRealization(
oldSpeakerCharacterId,
`想起來,那句話其實不是我說的——是我記錯了。`,
now,
);
}
await this.recordRealization(newSpeakerCharacterId, `原來那句話其實是我說的,之前記混了。`, now);
}
private async recordRealization(characterId: string, content: string, now: Date): Promise<void> {
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content,
occurredAt: now,
emotionTag: "CALM",
emotionIntensity: 0.3,
source: "CORRECTION",
weight: 1,
},
});
}
}
+51
View File
@@ -0,0 +1,51 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { SIGNAL_TOKENS, FINGERPRINT_MIN_SAMPLES } from "./constants.js";
export type Fingerprint = Record<string, number>; // token -> 出現比例 0~1
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
// M-5/M-6 語言指紋:從已確認歸屬的台詞語料統計特徵詞頻率,供歸屬判斷與矛盾回檢使用。
// 前幾章的指紋樣本很少時,統計不具代表性——樣本量門檻由 matchScore 的呼叫端(AttributionService)處理。
@Injectable()
export class FingerprintService {
constructor(private readonly prisma: PrismaService) {}
async computeFingerprint(characterId: string): Promise<{ fingerprint: Fingerprint; sampleCount: number }> {
const entries = await this.prisma.client.languageCorpusEntry.findMany({ where: { characterId } });
const fingerprint: Fingerprint = {};
for (const token of SIGNAL_TOKENS) {
const hits = entries.filter((entry) => entry.text.includes(token)).length;
fingerprint[token] = entries.length > 0 ? hits / entries.length : 0;
}
return { fingerprint, sampleCount: entries.length };
}
// 分數解讀:0.5 為中性(文本沒有任何特徵詞,或指紋樣本不足以判斷);
// 命中角色慣用的特徵詞 → 加分;命中角色從不使用的特徵詞(指紋矛盾) → 顯著扣分。
async matchScore(text: string, characterId: string): Promise<number> {
const { fingerprint, sampleCount } = await this.computeFingerprint(characterId);
const hitTokens = SIGNAL_TOKENS.filter((token) => text.includes(token));
if (hitTokens.length === 0) {
return 0.5;
}
if (sampleCount < FINGERPRINT_MIN_SAMPLES) {
return 0.5; // 指紋尚未建立,中性看待,不因樣本不足誤判矛盾。
}
let score = 0.5;
for (const token of hitTokens) {
const freq = fingerprint[token];
if (freq > 0.3) {
score += 0.4;
} else if (freq > 0) {
score += 0.1;
} else {
score -= 0.4; // 明顯矛盾:這個角色的既有語料從未出現過這個特徵詞。
}
}
return clamp01(score);
}
}
@@ -0,0 +1,60 @@
import { Injectable } from "@nestjs/common";
import type { IllustrationType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
export interface RegisterIllustrationInput {
workId: string;
volume: number;
page?: number;
type: IllustrationType;
sceneId?: string;
description: string;
costumeLabels?: { characterId: string; label: string }[];
poseLabels?: { characterId: string; label: string }[];
}
// M-1 插圖索引:不儲存受版權保護的原圖,只登錄畫面描述;同時衍生服裝目錄與姿勢語彙表
// ——立繪服裝層之後只能從這個目錄取用,不可自創(見 O 群組立繪子系統會消費這裡的資料)。
@Injectable()
export class IllustrationService {
constructor(private readonly prisma: PrismaService) {}
async register(input: RegisterIllustrationInput) {
const illustration = await this.prisma.client.illustration.create({
data: {
workId: input.workId,
volume: input.volume,
page: input.page,
type: input.type,
sceneId: input.sceneId,
description: input.description,
},
});
for (const costume of input.costumeLabels ?? []) {
await this.prisma.client.costumeCatalogEntry.create({
data: { characterId: costume.characterId, illustrationId: illustration.id, label: costume.label },
});
}
for (const pose of input.poseLabels ?? []) {
await this.prisma.client.poseVocabularyEntry.create({
data: { characterId: pose.characterId, illustrationId: illustration.id, label: pose.label },
});
}
return illustration;
}
// M-1 驗收:可由事件(場景)查到對應插圖。
async findByScene(sceneId: string) {
return this.prisma.client.illustration.findMany({ where: { sceneId } });
}
async listCostumeCatalog(characterId: string) {
return this.prisma.client.costumeCatalogEntry.findMany({ where: { characterId } });
}
async listPoseVocabulary(characterId: string) {
return this.prisma.client.poseVocabularyEntry.findMany({ where: { characterId } });
}
}
+105
View File
@@ -0,0 +1,105 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
import { HIGH_EMOTION_THRESHOLD } from "../memory/constants.js";
const CANON_EPOCH_MS = new Date("2000-01-01T00:00:00.000Z").getTime();
const DAY_MS = 24 * 60 * 60 * 1000;
// 場景資料庫只有「年表排序鍵」(storyOrder),沒有真實日期;投影成 EpisodicMemory 時需要一個 DateTime,
// 用固定虛擬曆元 + storyOrder 天數換算即可——只要求相對順序正確,不代表任何真實日期。
function storyOrderToDate(storyOrder: number): Date {
return new Date(CANON_EPOCH_MS + storyOrder * DAY_MS);
}
// M-4/M-7 多角色投影:共用場景資料庫只處理一次,各角色資料是投影出來的——修正場景資料庫一處,
// 重新投影後所有受影響角色同步更新。M-9 知識邊界:只有 storyOrder <= 錨定點的場景才會被投影。
@Injectable()
export class ProjectionService {
constructor(
private readonly prisma: PrismaService,
private readonly tagger: RuleBasedEmotionTagger,
) {}
async projectScene(sceneId: string): Promise<void> {
const scene = await this.prisma.client.scene.findUniqueOrThrow({
where: { id: sceneId },
include: { participants: true, lines: true, interactions: true },
});
const presentCharacterIds = scene.participants.map((p) => p.characterId);
const occurredAt = storyOrderToDate(scene.storyOrder);
// 1) 事件 → 情節記憶:她不在場的不記得——只有在場角色才會拿到這場戲的情節記憶。
const signal = this.tagger.tag({ text: scene.summary });
const hasMonologue = scene.lines.some((line) => line.lineType === "MONOLOGUE" && line.status === "CONFIRMED");
const isHighEmotion = signal.intensity >= HIGH_EMOTION_THRESHOLD;
// M-8 情緒權重評分:文本篇幅與心理描寫深度是天然的情緒權重指標。
const weight = (isHighEmotion ? 1.5 : 0.8) * (hasMonologue ? 1.4 : 1) * (1 + Math.min(1, scene.rawText.length / 500));
for (const characterId of presentCharacterIds) {
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content: scene.summary,
occurredAt,
emotionTag: signal.tag,
emotionIntensity: signal.intensity || 0.3,
source: "SOURCE_EXTRACTION",
weight,
sourceSceneId: scene.id,
},
});
}
// 2) 台詞 → 語言風格語料:已由 AttributionService 在確認歸屬的當下寫入 LanguageCorpusEntry,這裡不重複處理。
// 3) 內心獨白/心理描寫 → 性格參數證據。
const monologueLines = scene.lines.filter(
(line) => line.lineType === "MONOLOGUE" && line.status === "CONFIRMED" && line.speakerCharacterId,
);
for (const line of monologueLines) {
await this.prisma.client.personalityTraitSnapshot.create({
data: {
characterId: line.speakerCharacterId!,
sourceSceneId: scene.id,
atStoryOrder: scene.storyOrder,
note: line.rawText,
},
});
}
// 4) 與他人的互動 → 關係帳本(角色對角色):中立記錄在此刻才真正套用到雙方視角。
for (const interaction of scene.interactions) {
await this.prisma.client.characterRelationshipEventLog.create({
data: {
characterId: interaction.characterId,
otherCharacterId: interaction.otherCharacterId,
sceneId: scene.id,
delta: interaction.delta,
description: interaction.description,
},
});
}
// 5) 新登場設定 → 語意記憶:NARRATION 為場景中立敘述,在場角色都「知道」這件事。
const narrationLines = scene.lines.filter((line) => line.lineType === "NARRATION" && line.status === "CONFIRMED");
for (const line of narrationLines) {
for (const characterId of presentCharacterIds) {
await this.prisma.client.semanticMemory.create({
data: { characterId, fact: line.rawText, about: "世界觀", sourceSceneId: scene.id },
});
}
}
await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: true } });
}
// M-7 重新投影:修正場景資料庫後,先移除這個場景先前投影出的所有資料,再重新投影一次。
// 只清「由這個場景投影而來」的紀錄(sourceSceneId 指到它)——使用者互動記憶從不設這個欄位,永遠不受影響。
async reprojectScene(sceneId: string): Promise<void> {
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: sceneId } });
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: sceneId } });
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: sceneId } });
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId } });
await this.projectScene(sceneId);
}
}
+117
View File
@@ -0,0 +1,117 @@
import { Injectable } from "@nestjs/common";
import type { SceneLineType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
import { AttributionService } from "./attribution.service.js";
import { ProjectionService } from "./projection.service.js";
import { WorkRosterService } from "./work-roster.service.js";
export interface ImportLineInput {
lineType: SceneLineType;
rawText: string;
hasExplicitMarker?: boolean;
markerCharacterId?: string;
}
export interface ImportInteractionInput {
characterId: string;
otherCharacterId: string;
delta: number;
description: string;
}
export interface ImportFactInput {
eventLabel: string;
factKey: string;
factValue: string;
}
export interface ImportSceneInput {
workId: string;
volume: number;
chapterLabel: string;
storyOrder: number;
sourceType?: string;
summary: string;
rawText: string;
participantCharacterIds: string[];
lines: ImportLineInput[];
interactions?: ImportInteractionInput[];
facts?: ImportFactInput[];
}
// M-3 章節匯入與場景切分:以場景為單位寫入共用場景資料庫(角色中立的客觀記錄)。
// 場景本身的切分(章節文本 → 一個個場景)由呼叫端(人工或前置流程)完成後以結構化輸入送進來——
// 這裡不做自由文本的自動場景邊界偵測,理由同 F-7 MockProvider:對應的真實 NLP 能力屬於之後才會補的範疇。
@Injectable()
export class SceneImportService {
constructor(
private readonly prisma: PrismaService,
private readonly attribution: AttributionService,
private readonly projection: ProjectionService,
private readonly roster: WorkRosterService,
) {}
async importScene(input: ImportSceneInput) {
const scene = await this.prisma.client.scene.create({
data: {
workId: input.workId,
volume: input.volume,
chapterLabel: input.chapterLabel,
storyOrder: input.storyOrder,
sourceType: input.sourceType ?? "NOVEL",
summary: input.summary,
rawText: input.rawText,
},
});
for (const characterId of input.participantCharacterIds) {
await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } });
}
for (const [lineIndex, line] of input.lines.entries()) {
await this.prisma.client.sceneLine.create({
data: {
sceneId: scene.id,
lineIndex,
lineType: line.lineType,
rawText: line.rawText,
hasExplicitMarker: line.hasExplicitMarker ?? false,
markerCharacterId: line.markerCharacterId,
},
});
}
for (const interaction of input.interactions ?? []) {
await this.prisma.client.sceneInteraction.create({
data: {
sceneId: scene.id,
characterId: interaction.characterId,
otherCharacterId: interaction.otherCharacterId,
delta: interaction.delta,
description: interaction.description,
},
});
}
for (const fact of input.facts ?? []) {
await this.prisma.client.sceneFact.create({
data: { sceneId: scene.id, eventLabel: fact.eventLabel, factKey: fact.factKey, factValue: fact.factValue },
});
}
await this.attribution.attributeScene(scene.id);
// M-9 知識邊界:只有在錨定點之內的場景才立刻投影;超前的場景先留在場景資料庫,等錨定點推進時再載入。
// 建置門檻的「實質互動」次數只在投影後才算數(互動要先套用到關係帳本才算數),因此門檻檢查必須排在投影之後。
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: input.workId } });
if (scene.storyOrder <= work.anchorStoryOrder) {
await this.projection.projectScene(scene.id);
}
for (const characterId of input.participantCharacterIds) {
await this.roster.checkAndPromote(characterId);
}
return this.prisma.client.scene.findUniqueOrThrow({ where: { id: scene.id }, include: { participants: true, lines: true } });
}
}
+67
View File
@@ -0,0 +1,67 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { SOURCE_PRIORITY } from "./constants.js";
// M-8 跨章節整合:共用年表排序(Scene.storyOrder 本身即年表鍵,不需要另外的服務排序)、
// 矛盾偵測與裁決優先序(小說原文 > 官方設定集 > 動畫改編),無法裁決者標記待人工確認。
@Injectable()
export class TimelineService {
constructor(private readonly prisma: PrismaService) {}
// 掃描同一事件標籤下、同一事實鍵卻不同事實值的場景——這是故意置入矛盾最容易驗證的形式。
async detectContradictions(workId: string): Promise<number> {
const facts = await this.prisma.client.sceneFact.findMany({
where: { scene: { workId } },
include: { scene: true },
});
const groups = new Map<string, typeof facts>();
for (const fact of facts) {
const key = `${fact.eventLabel}::${fact.factKey}`;
const group = groups.get(key) ?? [];
group.push(fact);
groups.set(key, group);
}
let created = 0;
for (const [key, group] of groups) {
const distinctValues = new Set(group.map((fact) => fact.factValue));
if (distinctValues.size <= 1) {
continue;
}
const [eventLabel, factKey] = key.split("::");
const existing = await this.prisma.client.sceneContradiction.findFirst({ where: { workId, eventLabel, factKey } });
if (existing) {
continue;
}
const sorted = [...group].sort(
(a, b) => (SOURCE_PRIORITY[a.scene.sourceType] ?? 99) - (SOURCE_PRIORITY[b.scene.sourceType] ?? 99),
);
const best = sorted[0];
const runnerUpDifferentValue = sorted.find((fact) => fact.factValue !== best.factValue);
const canAutoResolve =
runnerUpDifferentValue !== undefined &&
(SOURCE_PRIORITY[best.scene.sourceType] ?? 99) < (SOURCE_PRIORITY[runnerUpDifferentValue.scene.sourceType] ?? 99);
await this.prisma.client.sceneContradiction.create({
data: {
workId,
eventLabel,
factKey,
description: `「${eventLabel}」的「${factKey}」出現不同描述:${[...distinctValues].join(" vs ")}`,
status: canAutoResolve ? "RESOLVED" : "PENDING_HUMAN_REVIEW",
resolution: canAutoResolve
? `依裁決優先序(小說原文 > 官方設定集 > 動畫改編)採用「${best.factValue}」(來源:${best.scene.sourceType})`
: null,
},
});
created += 1;
}
return created;
}
async listContradictions(workId: string) {
return this.prisma.client.sceneContradiction.findMany({ where: { workId } });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { BUILD_THRESHOLD_SCENES, BUILD_THRESHOLD_NAMED_LINES, BUILD_THRESHOLD_INTERACTIONS } from "./constants.js";
export interface RosterProgress {
characterId: string;
sceneCount: number;
namedLineCount: number;
interactionCount: number;
meetsThreshold: boolean;
}
// M-2 作品名冊與建置門檻:新角色先進候補狀態,資料照常在場景資料庫累積,達門檻才正式建置。
// 累積量一律即時查詢(場景/台詞/互動紀錄本身就是唯一真相),不維護額外的計數快取,避免同步漂移。
@Injectable()
export class WorkRosterService {
constructor(private readonly prisma: PrismaService) {}
async getProgress(characterId: string): Promise<RosterProgress> {
const [sceneCount, namedLineCount, interactionCount] = await Promise.all([
this.prisma.client.scenePresence.count({ where: { characterId } }),
this.prisma.client.sceneLine.count({ where: { speakerCharacterId: characterId, status: "CONFIRMED" } }),
this.prisma.client.characterRelationshipEventLog.count({ where: { characterId } }),
]);
return {
characterId,
sceneCount,
namedLineCount,
interactionCount,
meetsThreshold:
sceneCount >= BUILD_THRESHOLD_SCENES &&
namedLineCount >= BUILD_THRESHOLD_NAMED_LINES &&
interactionCount >= BUILD_THRESHOLD_INTERACTIONS,
};
}
// 匯入場景後對每個候補角色呼叫:達門檻就自動升級為正式建置,之後的匯入走「強化路徑」(不需要特別的程式碼分支
// ——強化與候補累積本來就是同一組寫入邏輯,差別只在 buildStatus 這個查詢結果)。
async checkAndPromote(characterId: string): Promise<boolean> {
const character = await this.prisma.client.character.findUnique({ where: { id: characterId } });
if (!character || character.buildStatus === "BUILT") {
return false;
}
const progress = await this.getProgress(characterId);
if (!progress.meetsThreshold) {
return false;
}
await this.prisma.client.character.update({ where: { id: characterId }, data: { buildStatus: "BUILT" } });
return true;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// 情緒標籤類型:對應情緒子系統六種狀態(見 D 群組)
export type EmotionTag = "CALM" | "JOY" | "SAD" | "ALERT" | "SHY" | "GRUMPY";
export type MemorySource = "INTERACTION" | "OFFLINE_GENERATED" | "SOURCE_EXTRACTION";
export type MemorySource = "INTERACTION" | "OFFLINE_GENERATED" | "SOURCE_EXTRACTION" | "CORRECTION";
export interface EpisodicMemory {
id: string;
@@ -0,0 +1,193 @@
-- CreateTable
CREATE TABLE "illustrations" (
"id" TEXT NOT NULL PRIMARY KEY,
"workId" TEXT NOT NULL,
"volume" INTEGER NOT NULL,
"page" INTEGER,
"type" TEXT NOT NULL,
"sceneId" TEXT,
"description" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "illustrations_workId_fkey" FOREIGN KEY ("workId") REFERENCES "works" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "illustrations_sceneId_fkey" FOREIGN KEY ("sceneId") REFERENCES "scenes" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "costume_catalog_entries" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"illustrationId" TEXT,
"label" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "costume_catalog_entries_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "costume_catalog_entries_illustrationId_fkey" FOREIGN KEY ("illustrationId") REFERENCES "illustrations" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "pose_vocabulary_entries" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"illustrationId" TEXT,
"label" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "pose_vocabulary_entries_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "pose_vocabulary_entries_illustrationId_fkey" FOREIGN KEY ("illustrationId") REFERENCES "illustrations" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "scenes" (
"id" TEXT NOT NULL PRIMARY KEY,
"workId" TEXT NOT NULL,
"volume" INTEGER NOT NULL,
"chapterLabel" TEXT NOT NULL,
"storyOrder" INTEGER NOT NULL,
"summary" TEXT NOT NULL,
"rawText" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "scenes_workId_fkey" FOREIGN KEY ("workId") REFERENCES "works" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "scene_presences" (
"id" TEXT NOT NULL PRIMARY KEY,
"sceneId" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
CONSTRAINT "scene_presences_sceneId_fkey" FOREIGN KEY ("sceneId") REFERENCES "scenes" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "scene_presences_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "scene_lines" (
"id" TEXT NOT NULL PRIMARY KEY,
"sceneId" TEXT NOT NULL,
"lineIndex" INTEGER NOT NULL,
"lineType" TEXT NOT NULL,
"rawText" TEXT NOT NULL,
"hasExplicitMarker" BOOLEAN NOT NULL DEFAULT false,
"markerCharacterId" TEXT,
"speakerCharacterId" TEXT,
"confidence" REAL NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "scene_lines_sceneId_fkey" FOREIGN KEY ("sceneId") REFERENCES "scenes" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "scene_lines_speakerCharacterId_fkey" FOREIGN KEY ("speakerCharacterId") REFERENCES "characters" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "scene_facts" (
"id" TEXT NOT NULL PRIMARY KEY,
"sceneId" TEXT NOT NULL,
"eventLabel" TEXT NOT NULL,
"factKey" TEXT NOT NULL,
"factValue" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "scene_facts_sceneId_fkey" FOREIGN KEY ("sceneId") REFERENCES "scenes" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "scene_contradictions" (
"id" TEXT NOT NULL PRIMARY KEY,
"workId" TEXT NOT NULL,
"eventLabel" TEXT NOT NULL,
"factKey" TEXT NOT NULL,
"description" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING_HUMAN_REVIEW',
"resolution" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "scene_contradictions_workId_fkey" FOREIGN KEY ("workId") REFERENCES "works" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "language_corpus_entries" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"sceneId" TEXT,
"text" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "language_corpus_entries_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "personality_trait_snapshots" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"atStoryOrder" INTEGER NOT NULL,
"note" TEXT NOT NULL,
"expressivenessOverride" REAL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "personality_trait_snapshots_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "character_relationship_event_logs" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"otherCharacterId" TEXT NOT NULL,
"sceneId" TEXT,
"delta" REAL NOT NULL,
"description" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "character_relationship_event_logs_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "character_relationship_event_logs_otherCharacterId_fkey" FOREIGN KEY ("otherCharacterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "character_relationship_event_logs_sceneId_fkey" FOREIGN KEY ("sceneId") REFERENCES "scenes" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_episodic_memories" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"occurredAt" DATETIME NOT NULL,
"emotionTag" TEXT NOT NULL,
"emotionIntensity" REAL NOT NULL,
"retrievalCount" INTEGER NOT NULL DEFAULT 0,
"lastRetrievedAt" DATETIME,
"source" TEXT NOT NULL,
"weight" REAL NOT NULL DEFAULT 1,
"relatedUserId" TEXT,
"isPrivate" BOOLEAN NOT NULL DEFAULT false,
"sourceSceneId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "episodic_memories_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "episodic_memories_relatedUserId_fkey" FOREIGN KEY ("relatedUserId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "episodic_memories_sourceSceneId_fkey" FOREIGN KEY ("sourceSceneId") REFERENCES "scenes" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_episodic_memories" ("characterId", "content", "createdAt", "emotionIntensity", "emotionTag", "id", "isPrivate", "lastRetrievedAt", "occurredAt", "relatedUserId", "retrievalCount", "source", "weight") SELECT "characterId", "content", "createdAt", "emotionIntensity", "emotionTag", "id", "isPrivate", "lastRetrievedAt", "occurredAt", "relatedUserId", "retrievalCount", "source", "weight" FROM "episodic_memories";
DROP TABLE "episodic_memories";
ALTER TABLE "new_episodic_memories" RENAME TO "episodic_memories";
CREATE TABLE "new_semantic_memories" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"fact" TEXT NOT NULL,
"about" TEXT NOT NULL,
"sourceSceneId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "semantic_memories_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "semantic_memories_sourceSceneId_fkey" FOREIGN KEY ("sourceSceneId") REFERENCES "scenes" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_semantic_memories" ("about", "characterId", "createdAt", "fact", "id") SELECT "about", "characterId", "createdAt", "fact", "id" FROM "semantic_memories";
DROP TABLE "semantic_memories";
ALTER TABLE "new_semantic_memories" RENAME TO "semantic_memories";
CREATE TABLE "new_works" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"volumeProgress" TEXT,
"worldview" TEXT,
"progressAnchor" TEXT,
"anchorStoryOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_works" ("createdAt", "id", "progressAnchor", "title", "updatedAt", "volumeProgress", "worldview") SELECT "createdAt", "id", "progressAnchor", "title", "updatedAt", "volumeProgress", "worldview" FROM "works";
DROP TABLE "works";
ALTER TABLE "new_works" RENAME TO "works";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "scenes_workId_storyOrder_key" ON "scenes"("workId", "storyOrder");
-- CreateIndex
CREATE UNIQUE INDEX "scene_presences_sceneId_characterId_key" ON "scene_presences"("sceneId", "characterId");
@@ -0,0 +1,35 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_personality_trait_snapshots" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"sourceSceneId" TEXT,
"atStoryOrder" INTEGER NOT NULL,
"note" TEXT NOT NULL,
"expressivenessOverride" REAL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "personality_trait_snapshots_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "personality_trait_snapshots_sourceSceneId_fkey" FOREIGN KEY ("sourceSceneId") REFERENCES "scenes" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_personality_trait_snapshots" ("atStoryOrder", "characterId", "createdAt", "expressivenessOverride", "id", "note") SELECT "atStoryOrder", "characterId", "createdAt", "expressivenessOverride", "id", "note" FROM "personality_trait_snapshots";
DROP TABLE "personality_trait_snapshots";
ALTER TABLE "new_personality_trait_snapshots" RENAME TO "personality_trait_snapshots";
CREATE TABLE "new_scenes" (
"id" TEXT NOT NULL PRIMARY KEY,
"workId" TEXT NOT NULL,
"volume" INTEGER NOT NULL,
"chapterLabel" TEXT NOT NULL,
"storyOrder" INTEGER NOT NULL,
"summary" TEXT NOT NULL,
"rawText" TEXT NOT NULL,
"projected" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "scenes_workId_fkey" FOREIGN KEY ("workId") REFERENCES "works" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_scenes" ("chapterLabel", "createdAt", "id", "rawText", "storyOrder", "summary", "volume", "workId") SELECT "chapterLabel", "createdAt", "id", "rawText", "storyOrder", "summary", "volume", "workId" FROM "scenes";
DROP TABLE "scenes";
ALTER TABLE "new_scenes" RENAME TO "scenes";
CREATE UNIQUE INDEX "scenes_workId_storyOrder_key" ON "scenes"("workId", "storyOrder");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,22 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_scenes" (
"id" TEXT NOT NULL PRIMARY KEY,
"workId" TEXT NOT NULL,
"volume" INTEGER NOT NULL,
"chapterLabel" TEXT NOT NULL,
"storyOrder" INTEGER NOT NULL,
"sourceType" TEXT NOT NULL DEFAULT 'NOVEL',
"summary" TEXT NOT NULL,
"rawText" TEXT NOT NULL,
"projected" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "scenes_workId_fkey" FOREIGN KEY ("workId") REFERENCES "works" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_scenes" ("chapterLabel", "createdAt", "id", "projected", "rawText", "storyOrder", "summary", "volume", "workId") SELECT "chapterLabel", "createdAt", "id", "projected", "rawText", "storyOrder", "summary", "volume", "workId" FROM "scenes";
DROP TABLE "scenes";
ALTER TABLE "new_scenes" RENAME TO "scenes";
CREATE UNIQUE INDEX "scenes_workId_storyOrder_key" ON "scenes"("workId", "storyOrder");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "scene_interactions" (
"id" TEXT NOT NULL PRIMARY KEY,
"sceneId" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"otherCharacterId" TEXT NOT NULL,
"delta" REAL NOT NULL,
"description" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "scene_interactions_sceneId_fkey" FOREIGN KEY ("sceneId") REFERENCES "scenes" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
+238 -1
View File
@@ -24,6 +24,7 @@ enum MemorySource {
INTERACTION // 互動
OFFLINE_GENERATED // 離線生成
SOURCE_EXTRACTION // 原作萃取
CORRECTION // M-10 回溯修正:發現記錯了,以「想起來其實不是這樣」的新事件消化,不竄改既有的 INTERACTION 記憶
}
// 情緒標籤類型:對應情緒子系統六種狀態(見 D 群組)
@@ -128,11 +129,15 @@ model Work {
title String
volumeProgress String? // 卷數進度
worldview String? // 世界觀
progressAnchor String? // 作品進度錨定點(M-9/N 群組使用;描述作品實際描寫過的時期)
progressAnchor String? // 作品進度錨定點(M-9/N 群組使用;描述作品實際描寫過的時期,人類可讀)
anchorStoryOrder Int @default(0) // M-9 錨定點的數值刀口:對應 Scene.storyOrder,之前是親身經歷,之後不存在
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
characters Character[]
scenes Scene[]
illustrations Illustration[]
contradictions SceneContradiction[]
@@map("works")
}
@@ -174,6 +179,14 @@ model Character {
ageEvidence CharacterAgeEvidence[]
romanceEventLogs RomanceEventLog[]
adultContentAttempts AdultContentAttemptLog[]
scenePresences ScenePresence[]
spokenLines SceneLine[]
costumeCatalogEntries CostumeCatalogEntry[]
poseVocabularyEntries PoseVocabularyEntry[]
languageCorpusEntries LanguageCorpusEntry[]
personalityTraitSnapshots PersonalityTraitSnapshot[]
relationshipEventLogsFrom CharacterRelationshipEventLog[] @relation("EventLogFrom")
relationshipEventLogsTo CharacterRelationshipEventLog[] @relation("EventLogTo")
@@map("characters")
}
@@ -325,6 +338,10 @@ model EpisodicMemory {
relatedUserId String? // 這段記憶與哪位使用者相關(E-5 關係加權檢索用)
relatedUser User? @relation(fields: [relatedUserId], references: [id], onDelete: SetNull)
isPrivate Boolean @default(false) // K-3 隱私邊界:一對一聊過的私密內容,群聊中不主動洩漏(口風不緊原型除外)
// M-7/M-10 可追溯性:這筆記憶是否由某個場景投影而來。只有這裡非 null 的記錄會被「重新投影」刪除重建;
// 使用者互動記憶(source=INTERACTION)永遠不會設這個欄位,因此重新投影永遠不會動到使用者互動記錄。
sourceSceneId String?
sourceScene Scene? @relation(fields: [sourceSceneId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@map("episodic_memories")
@@ -336,6 +353,8 @@ model SemanticMemory {
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
fact String // 去情境化事實
about String // 對象(關於誰/什麼)
sourceSceneId String? // M-4/M-7 可追溯性:由哪個場景萃取而來(新登場設定)
sourceScene Scene? @relation(fields: [sourceSceneId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@map("semantic_memories")
@@ -457,3 +476,221 @@ model ProactiveCareLog {
@@map("proactive_care_logs")
}
// M-1 插圖類型:對立繪的用途不同——封面定基礎氣質、彩頁給服裝差分、黑白插圖給表情/姿勢差分。
enum IllustrationType {
COVER
COLOR_PAGE
BW_ILLUSTRATION
}
// M-1 插圖索引:不儲存受版權保護的原圖本身,只登錄「畫面描述」供服裝目錄/姿勢語彙/表情差分參考使用。
model Illustration {
id String @id @default(cuid())
workId String
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
volume Int
page Int?
type IllustrationType
sceneId String? // 對應場景(插圖與文本的交叉索引)
scene Scene? @relation(fields: [sceneId], references: [id], onDelete: SetNull)
description String // 畫面描述:表情/姿勢/服裝等文字記錄
createdAt DateTime @default(now())
costumeCatalogEntries CostumeCatalogEntry[]
poseVocabularyEntries PoseVocabularyEntry[]
@@map("illustrations")
}
// M-1 服裝目錄:逐卷登錄角色出現過的服裝,立繪服裝層只能從這個目錄取用,不可自創。
model CostumeCatalogEntry {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
illustrationId String?
illustration Illustration? @relation(fields: [illustrationId], references: [id], onDelete: SetNull)
label String // 例如「制服」「便服」「禮服」
createdAt DateTime @default(now())
@@map("costume_catalog_entries")
}
// M-1 姿勢語彙:繪師畫該角色時慣用的身體語言(總是抱著書、習慣性歪頭),納入立繪姿勢差分的官方認證來源。
model PoseVocabularyEntry {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
illustrationId String?
illustration Illustration? @relation(fields: [illustrationId], references: [id], onDelete: SetNull)
label String // 例如「總是抱著書」「習慣性歪頭」
createdAt DateTime @default(now())
@@map("pose_vocabulary_entries")
}
// M-4 場景內逐行的類型:對應五類萃取產物裡「事件」以外,需要逐行分類才能各自導向正確目的地的部分。
enum SceneLineType {
DIALOGUE // 台詞 → 語言風格語料
ACTION // 動作描寫 → 事件的一部分
MONOLOGUE // 內心獨白/心理描寫 → 性格參數證據
NARRATION // 敘述/新登場設定 → 語意記憶
}
enum SceneLineStatus {
PENDING // 尚待歸屬判定
CONFIRMED // 信心分數達門檻且指紋一致,已寫入場景資料庫
QUARANTINED // 低信心或指紋矛盾,隔離、不寫入任何角色
}
// M-3 共用場景資料庫:章節文本以場景為單位切分後的客觀記錄單位,角色中立,不含主觀詮釋。
// M-8/M-9 的共用年表以 storyOrder 排序——同作品所有場景共用一條數線,錨定點即在此數線上切一刀。
model Scene {
id String @id @default(cuid())
workId String
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
volume Int
chapterLabel String
storyOrder Int // 全作品共用年表排序鍵
sourceType String @default("NOVEL") // M-8 矛盾裁決優先序用:NOVEL/OFFICIAL_SETTEI/ANIME_ADAPTATION
summary String // 場景摘要(客觀記錄:在場者、誰說了哪句、誰做了什麼)
rawText String // 匯入的原始場景文本(僅供測試/自製文本使用,不得為受版權保護原文)
projected Boolean @default(false) // M-9:是否已投影進角色記憶(只有 storyOrder <= 錨定點的場景會被投影)
createdAt DateTime @default(now())
participants ScenePresence[]
lines SceneLine[]
illustrations Illustration[]
facts SceneFact[]
episodicMemories EpisodicMemory[]
semanticMemories SemanticMemory[]
relationshipEventLogs CharacterRelationshipEventLog[]
personalityTraitSnapshots PersonalityTraitSnapshot[]
interactions SceneInteraction[]
@@unique([workId, storyOrder])
@@map("scenes")
}
// M-3/M-5 場景名冊(硬約束):不在場的角色不可能說話,歸屬候選只限在場者。
model ScenePresence {
id String @id @default(cuid())
sceneId String
scene Scene @relation(fields: [sceneId], references: [id], onDelete: Cascade)
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@unique([sceneId, characterId])
@@map("scene_presences")
}
// M-4/M-5/M-6 場景內逐行記錄:每一行都有獨立的歸屬信心分數與狀態,是隔離區機制運作的最小單位。
model SceneLine {
id String @id @default(cuid())
sceneId String
scene Scene @relation(fields: [sceneId], references: [id], onDelete: Cascade)
lineIndex Int
lineType SceneLineType
rawText String
hasExplicitMarker Boolean @default(false) // 是否有「○○說道」等明示標記(決定性證據)
markerCharacterId String? // 明示標記指向的角色(若有)
speakerCharacterId String? // 目前歸屬(可能是明示、也可能是推論後確認的結果)
speakerCharacter Character? @relation(fields: [speakerCharacterId], references: [id], onDelete: SetNull)
confidence Float @default(0)
status SceneLineStatus @default(PENDING)
createdAt DateTime @default(now())
@@map("scene_lines")
}
// M-8 場景事實:客觀、可檢查的事實標記(例如天氣),跨場景比對同一事件標籤下的事實是否矛盾。
model SceneFact {
id String @id @default(cuid())
sceneId String
scene Scene @relation(fields: [sceneId], references: [id], onDelete: Cascade)
eventLabel String // 事件標籤:同一事件標籤下的事實理應一致(例如「秋季運動會」)
factKey String // 例如「天氣」
factValue String // 例如「雨天」
createdAt DateTime @default(now())
@@map("scene_facts")
}
// M-4 互動的中立記錄:場景資料庫階段只記錄「發生了什麼互動」,尚未套用到任何角色的關係帳本——
// 套用(寫入 CharacterRelationshipEventLog)是投影階段的職責,一樣受 M-9 錨定點知識邊界節制。
model SceneInteraction {
id String @id @default(cuid())
sceneId String
scene Scene @relation(fields: [sceneId], references: [id], onDelete: Cascade)
characterId String
otherCharacterId String
delta Float
description String
createdAt DateTime @default(now())
@@map("scene_interactions")
}
enum ContradictionStatus {
PENDING_HUMAN_REVIEW
RESOLVED
}
// M-8 矛盾偵測:不同場景描寫衝突時標記待人工確認;裁決優先序見 ContradictionDetectionService。
model SceneContradiction {
id String @id @default(cuid())
workId String
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
eventLabel String
factKey String
description String
status ContradictionStatus @default(PENDING_HUMAN_REVIEW)
resolution String?
createdAt DateTime @default(now())
@@map("scene_contradictions")
}
// M-4/M-6 語言風格語料:已確認歸屬的台詞樣本,供語言指紋統計(人稱/語尾/口癖頻率)使用。
model LanguageCorpusEntry {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
sceneId String?
text String
createdAt DateTime @default(now())
@@map("language_corpus_entries")
}
// M-8 性格演變追蹤:參數不是單一值而是帶時間戳的序列,依錨定點取用當時的性格參數。
model PersonalityTraitSnapshot {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
sourceSceneId String? // M-4/M-7 可追溯性:由哪個場景的獨白/心理描寫萃取而來
sourceScene Scene? @relation(fields: [sourceSceneId], references: [id], onDelete: SetNull)
atStoryOrder Int // 對應的年表位置
note String // 描述性文字:這個時間點角色參數上的變化說明
expressivenessOverride Float? // 可選:覆寫 G-1 外顯度參數,模擬成長軌跡
createdAt DateTime @default(now())
@@map("personality_trait_snapshots")
}
// M-4 互動→關係帳本(角色對角色版):每次原作互動評正負向權重,累積出她對每個角色的信任/親密度,
// 可追溯到來源場景;同一事件寫入雙方視角,權重可以不對稱。
model CharacterRelationshipEventLog {
id String @id @default(cuid())
characterId String
character Character @relation("EventLogFrom", fields: [characterId], references: [id], onDelete: Cascade)
otherCharacterId String
otherCharacter Character @relation("EventLogTo", fields: [otherCharacterId], references: [id], onDelete: Cascade)
sceneId String?
scene Scene? @relation(fields: [sceneId], references: [id], onDelete: SetNull)
delta Float
description String
createdAt DateTime @default(now())
@@map("character_relationship_event_logs")
}
+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 } });
}
+22 -11
View File
@@ -328,17 +328,28 @@ flowchart TB
### M. 既有作品考據與輕小說章節管線
- [ ] **M-1 插圖索引(S)**:登錄插圖(卷數/頁碼/對應場景/類型=封面/彩頁/黑白),並衍生服裝目錄與姿勢語彙表;立繪服裝層只能從目錄取用、不自創。驗收:可由事件查到對應插圖,服裝目錄可列出。依據:§插圖作為立繪參考、§插圖與文本的交叉索引。
- [ ] **M-2 作品名冊與建置門檻(S)**:角色名冊登錄正式名/暱稱/他人稱呼,新角色先進候補狀態,達門檻(出場場景數、具名台詞數、與主要角色實質互動)才正式建置。驗收:路人角色維持候補、主要角色達標後自動建置。依據:§章節匯入流程:強化或新建、§建置門檻。
- [ ] **M-3 章節匯入與場景切分(M)**:匯入章節文本,以場景為單位切分並寫入**共用場景資料庫**(客觀記錄:在場者、誰說了哪句、誰做了什麼,不含主觀詮釋)。驗收:以自製測試文本匯入後可列出場景與在場者。依據:§共用場景資料庫:處理一次,各自投影。註記:測試文本不得使用受版權保護的原文(需人工確認來源)。
- [ ] **M-4 逐場景萃取(M)**:萃取事件、目標角色台詞、內心獨白/心理描寫、與他人的互動、新登場設定五類產物。驗收:五類產物各自入庫且可追溯到來源場景。依據:§逐章處理管線。
- [ ] **M-5 歸屬信心分數(M)**:每句台詞/動作的歸屬附信心分數,證據強度依序為 明示標記(決定性)>語言指紋(強)>場景名冊(硬約束:不在場者不可能說話)>對話輪替(中)>內容合理性(中)。驗收:無標記對話能給出帶信心分數的歸屬。依據:§防線一:歸屬時的多重證據與信心分數。
- [ ] **M-6 隔離區與指紋冷啟動(M)**:信心低於門檻或與既有語言指紋矛盾者一律進隔離區、不寫入任何角色,累積成待裁決清單;前幾章先用有明示標記的台詞建立指紋基準再回頭處理無標記對話。驗收:低信心台詞不污染角色語料,裁決後可釋放入庫。依據:§防線二:寫入前的交叉驗證。
- [ ] **M-7 多角色投影(M)**:由場景資料庫投影出各角色的情節記憶(以她的視角改寫、她不在場的不記得、她誤解的按她以為的版本存)、語料與關係帳本事件;修正只改場景資料庫一處後重新投影。驗收:修正一句台詞歸屬後,受影響的多個角色資料同步更新。依據:§共用場景資料庫、§各類萃取物的處理規則。
- [ ] **M-8 跨章節整合(M)**:共用年表排序、依文本篇幅與心理描寫深度評情緒權重、矛盾偵測與裁決優先序(小說原文 > 官方設定集 > 動畫改編),無法裁決標「待人工確認」。驗收:故意置入的矛盾被偵測並標記。依據:§跨章節整合的關鍵。
- [ ] **M-9 錨定點與知識邊界(M)**:作品進度錨定點統一於作品層級,錨定點之前為角色的親身經歷、之後不存在;性格參數為帶時間戳的序列,依錨定點取值;同作品角色共用錨定點但年齡逐角色判定。驗收:錨定點前移後,角色不再知道後段劇情。依據:§跨章節整合「時間點錨定」、§錨定時間點的統一。
- [ ] **M-10 增量補全與回溯修正(M)**:新卷匯入時追加至年表尾端(錨定點之後者存為未啟用);新資訊可回溯修正舊參數;已上線角色發現歷史歸屬錯誤時,修正場景資料庫 → 重新投影 → 重算指紋與關係帳本,但**與使用者已發生的互動記憶不回溯竄改**,改以「想起來其實不是這樣」的新互動事件消化。驗收:修正後使用者互動記錄完整保留。依據:§增量補全、§誤判發現得晚怎麼辦。
- [ ] **M-V 階段驗證(S)**:`npm run restart && npm run smoke -- M`(M.mjs:匯入測試文本 → 場景記錄 → 兩角色投影 → 客觀事實一致性檢核通過、主觀詮釋差異被允許)。
- [x] **M-1 插圖索引(S)**:登錄插圖(卷數/頁碼/對應場景/類型=封面/彩頁/黑白),並衍生服裝目錄與姿勢語彙表;立繪服裝層只能從目錄取用、不自創。驗收:可由事件查到對應插圖,服裝目錄可列出。依據:§插圖作為立繪參考、§插圖與文本的交叉索引。
- [x] **M-2 作品名冊與建置門檻(S)**:角色名冊登錄正式名/暱稱/他人稱呼,新角色先進候補狀態,達門檻(出場場景數、具名台詞數、與主要角色實質互動)才正式建置。驗收:路人角色維持候補、主要角色達標後自動建置。依據:§章節匯入流程:強化或新建、§建置門檻。
- [x] **M-3 章節匯入與場景切分(M)**:匯入章節文本,以場景為單位切分並寫入**共用場景資料庫**(客觀記錄:在場者、誰說了哪句、誰做了什麼,不含主觀詮釋)。驗收:以自製測試文本匯入後可列出場景與在場者。依據:§共用場景資料庫:處理一次,各自投影。註記:測試文本不得使用受版權保護的原文(需人工確認來源)。
- [x] **M-4 逐場景萃取(M)**:萃取事件、目標角色台詞、內心獨白/心理描寫、與他人的互動、新登場設定五類產物。驗收:五類產物各自入庫且可追溯到來源場景。依據:§逐章處理管線。
- [x] **M-5 歸屬信心分數(M)**:每句台詞/動作的歸屬附信心分數,證據強度依序為 明示標記(決定性)>語言指紋(強)>場景名冊(硬約束:不在場者不可能說話)>對話輪替(中)>內容合理性(中)。驗收:無標記對話能給出帶信心分數的歸屬。依據:§防線一:歸屬時的多重證據與信心分數。
- [x] **M-6 隔離區與指紋冷啟動(M)**:信心低於門檻或與既有語言指紋矛盾者一律進隔離區、不寫入任何角色,累積成待裁決清單;前幾章先用有明示標記的台詞建立指紋基準再回頭處理無標記對話。驗收:低信心台詞不污染角色語料,裁決後可釋放入庫。依據:§防線二:寫入前的交叉驗證。
- [x] **M-7 多角色投影(M)**:由場景資料庫投影出各角色的情節記憶(以她的視角改寫、她不在場的不記得、她誤解的按她以為的版本存)、語料與關係帳本事件;修正只改場景資料庫一處後重新投影。驗收:修正一句台詞歸屬後,受影響的多個角色資料同步更新。依據:§共用場景資料庫、§各類萃取物的處理規則。
- [x] **M-8 跨章節整合(M)**:共用年表排序、依文本篇幅與心理描寫深度評情緒權重、矛盾偵測與裁決優先序(小說原文 > 官方設定集 > 動畫改編),無法裁決標「待人工確認」。驗收:故意置入的矛盾被偵測並標記。依據:§跨章節整合的關鍵。
- [x] **M-9 錨定點與知識邊界(M)**:作品進度錨定點統一於作品層級,錨定點之前為角色的親身經歷、之後不存在;性格參數為帶時間戳的序列,依錨定點取值;同作品角色共用錨定點但年齡逐角色判定。驗收:錨定點前移後,角色不再知道後段劇情。依據:§跨章節整合「時間點錨定」、§錨定時間點的統一。
- [x] **M-10 增量補全與回溯修正(M)**:新卷匯入時追加至年表尾端(錨定點之後者存為未啟用);新資訊可回溯修正舊參數;已上線角色發現歷史歸屬錯誤時,修正場景資料庫 → 重新投影 → 重算指紋與關係帳本,但**與使用者已發生的互動記憶不回溯竄改**,改以「想起來其實不是這樣」的新互動事件消化。驗收:修正後使用者互動記錄完整保留。依據:§增量補全、§誤判發現得晚怎麼辦。
- [x] **M-V 階段驗證(S)**:`npm run restart && npm run smoke -- M`(M.mjs:匯入測試文本 → 場景記錄 → 兩角色投影 → 客觀事實一致性檢核通過、主觀詮釋差異被允許)。
> **實作記錄(M 群組)**:
> - 新模組放在 `apps/api/src/canon/`。**M-3 場景切分的刻意簡化**:這裡不做自由文本的自動場景邊界偵測(真正的章節文本→場景切分需要相當於一個 NLP 分段模型),改成接受「呼叫端已經切好場景、逐行標好類型」的結構化輸入(`ImportSceneInput`:`lines: [{lineType, rawText, hasExplicitMarker?, markerCharacterId?}]`)——這個簡化與 F-7 `MockProvider` 代替真實 LLM 是同一種取捨:引擎的其他機制(信心分數、隔離區、投影、錨定點)都能在沒有真正 NLP 分段器的情況下完整測試,之後要接上真的自動分段/自動偵測明示標記,只需要在 `SceneImportService` 前面補一層前處理,不影響下游任何機制。**M.mjs 的測試文本全部是本次會話自製的短句,不是任何受版權保護的原作內容**,符合 M-3 驗收註記的要求。
> - **場景資料庫(`Scene`/`ScenePresence`/`SceneLine`/`SceneFact`/`SceneInteraction`)是角色中立的客觀記錄,投影(寫入 `EpisodicMemory`/`SemanticMemory`/`PersonalityTraitSnapshot`/`CharacterRelationshipEventLog`)是完全獨立的第二步**,兩者由 `ProjectionService` 銜接。這個分層是 M-7「修正只改一處、重新投影全部同步」與 M-9「知識邊界」共同的地基:`Scene.projected` 這個布林欄位記錄「這場戲的效果有沒有套用到角色資料」,`ProjectionService.reprojectScene` 永遠是「先刪掉這個場景先前投影出的所有資料(用 `sourceSceneId`/`sceneId` 精準篩選),再重新投影一次」,不是就地修改——這保證了「改一處、處處同步」不會有殘留的舊資料。
> - **M-9 錨定點知識邊界是這個分層的直接推論,幾乎不需要額外程式碼**:`SceneImportService` 只在 `scene.storyOrder <= work.anchorStoryOrder` 時才呼叫 `projectScene`;超前的場景就乖乖留在場景資料庫裡等錨定點推進。`AnchorService.setAnchor` 同時處理**前進**(把新進入範圍的場景投影進去)與**後退**(把新排除在範圍外、但先前已投影的場景資料清掉,`Scene.projected` 打回 `false`)——**這裡刻意支援雙向**,因為「使用者想避免爆雷、把錨定點設回比之前更早的章節」是真實會發生的操作,不是只有「追完新一季往前推」這種單向情境。
> - **M-10「與使用者互動記憶不回溯竄改」是結構性保證,不是靠邏輯判斷做到的**:`sourceSceneId`/`sceneId` 這類可追溯欄位只有場景投影會寫入,`source: "INTERACTION"` 的記憶(一對一聊天產生的)從來不會有這些欄位——因此 `reprojectScene`/`AnchorService` 的刪除查詢天然就篩不到使用者互動記憶,不需要額外寫「排除 INTERACTION」的特殊判斷,少一行防禦性程式碼就少一個之後可能漏寫的風險點。已上線角色發現歷史歸屬錯誤時(`CorrectionService.correctLineAttribution`),除了觸發重新投影,還會分別幫舊歸屬角色與新歸屬角色各記一筆 `source: "CORRECTION"` 的新事件(「想起來其實不是這樣」),**新增了 `MemorySource.CORRECTION` 這個列舉值**(`packages/shared` 與 Prisma schema 都要同步加,兩邊型別要對得上,這次建置時就因為漏了 `packages/shared` 那邊而卡到一次型別錯誤)。
> - **M-5/M-6 語言指紋是「從語料統計學出來的」,不是 G 群組那種原型模板**:`FingerprintService` 從 `LanguageCorpusEntry`(已確認歸屬的台詞)統計一組特徵詞(`SIGNAL_TOKENS`)的出現頻率,跟 G-2 `language-style.ts` 裡手寫的「這個原型絕不用驚嘆號」是兩套完全不同的機制、不要混淆:G 群組的是**設計時鎖定**的角色語言風格規則,M 群組的指紋是**從已歸屬文本統計出來**的、會隨語料增加而變準的證據來源。指紋樣本量門檻(`FINGERPRINT_MIN_SAMPLES=3`)很重要:樣本不足時一律回傳中性分數 0.5,不會因為剛好命中一個特徵詞就誤判——冷啟動階段(只有明示標記台詞、語料還很少)本來就該保守,這正是 M-6「先用明示標記建立指紋基準,再回頭處理無標記對話」的字面意思。
> - **中文分詞的經驗延續**:K 群組在 `speaking-right.service.ts` 踩過「切詞正規表達式沒把全角標點當分隔符,導致單字特徵詞比對不到」的坑(見 K 群組實作記錄);這次 `fingerprint.service.ts`/`attribution.service.ts` 一開始就用單字級的 `text.includes(token)` 子字串比對(完全不切詞),直接繞開同一類分詞陷阱,是刻意選的簡化方案。
> - **建置門檻檢查(M-2)的呼叫順序是個真的踩到的 bug**:一開始 `checkAndPromote` 排在 `projectScene` 之前呼叫,導致「與其他角色的實質互動」這個門檻條件永遠用「上一次投影完」的舊資料去判斷(這次匯入場景帶來的新互動還沒套用到 `CharacterRelationshipEventLog`,因為那是投影階段才寫入的)——結果角色明明已經達標卻沒被自動建置。修正成**投影完才檢查門檻**,`AnchorService.setAnchor` 推進錨定點時也一樣要在對應場景投影完之後才檢查——**這類「事件觸發的統計門檻檢查」永遠要排在所有會影響統計結果的寫入完成之後**,這條經驗值得日後任何群組寫類似的自動判定邏輯時留意。
> - **角色間互動的關係帳本刻意寫雙方視角、且權重可以不對稱**:`SceneImportService` 接受的 `interactions` 輸入是呼叫端(人工/未來的萃取流程)明確給的「誰對誰做了什麼、正負權重多少」,並沒有做真正的情感分析去推論互動的正負向——這是本群組另一個「先用結構化輸入代替真實 NLP」的簡化,跟場景切分是同一種取捨,理由也相同:機制先做對,之後有真的 NLP 能力再接上去替換輸入來源即可,不影響 `CharacterRelationshipEventLog`/建置門檻/關係一起成長這些下游機制。
> - **M-8 矛盾偵測只處理「同一事件標籤下、同一事實鍵卻有不同事實值」這種最容易驗證的矛盾形式**(`SceneFact`:`eventLabel`+`factKey`+`factValue`),裁決優先序(小說原文 > 官方設定集 > 動畫改編)靠 `Scene.sourceType` 判斷;同優先序來源互相矛盾(例如兩段小說原文互相衝突)沒有客觀依據可以自動選邊,標記 `PENDING_HUMAN_REVIEW`,不會硬選一個。**沒有做語意層級的矛盾偵測**(例如兩段描述用不同措辭講同一件事實但沒有明確標成同一個 `eventLabel`)——這需要真正的自然語言理解,超出本群組範疇,留給日後有實際 LLM 介入文本處理階段時再擴充。
### N. 後日談模式