diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 259f882..7559e57 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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], }) diff --git a/apps/api/src/canon/anchor.service.ts b/apps/api/src/canon/anchor.service.ts new file mode 100644 index 0000000..b8b8d0b --- /dev/null +++ b/apps/api/src/canon/anchor.service.ts @@ -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" }, + }); + } +} diff --git a/apps/api/src/canon/attribution.service.ts b/apps/api/src/canon/attribution.service.ts new file mode 100644 index 0000000..e38f728 --- /dev/null +++ b/apps/api/src/canon/attribution.service.ts @@ -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 { + 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 { + 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 { + 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 { + await this.prisma.client.sceneLine.update({ where: { id: lineId }, data: { status: "QUARANTINED", confidence: 0 } }); + } + + // M-6 人工裁決:解除隔離,直接指定歸屬並寫入場景資料庫(含語料)。 + async adjudicate(lineId: string, speakerCharacterId: string): Promise { + 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 { + 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; + } +} diff --git a/apps/api/src/canon/canon.controller.ts b/apps/api/src/canon/canon.controller.ts new file mode 100644 index 0000000..7ed7eb1 --- /dev/null +++ b/apps/api/src/canon/canon.controller.ts @@ -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); + } +} diff --git a/apps/api/src/canon/canon.module.ts b/apps/api/src/canon/canon.module.ts new file mode 100644 index 0000000..3c50867 --- /dev/null +++ b/apps/api/src/canon/canon.module.ts @@ -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 {} diff --git a/apps/api/src/canon/constants.ts b/apps/api/src/canon/constants.ts new file mode 100644 index 0000000..74fabf2 --- /dev/null +++ b/apps/api/src/canon/constants.ts @@ -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 = { + NOVEL: 1, + OFFICIAL_SETTEI: 2, + ANIME_ADAPTATION: 3, +}; diff --git a/apps/api/src/canon/correction.service.ts b/apps/api/src/canon/correction.service.ts new file mode 100644 index 0000000..e01b349 --- /dev/null +++ b/apps/api/src/canon/correction.service.ts @@ -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 { + 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 { + await this.prisma.client.episodicMemory.create({ + data: { + characterId, + content, + occurredAt: now, + emotionTag: "CALM", + emotionIntensity: 0.3, + source: "CORRECTION", + weight: 1, + }, + }); + } +} diff --git a/apps/api/src/canon/fingerprint.service.ts b/apps/api/src/canon/fingerprint.service.ts new file mode 100644 index 0000000..a9eaa02 --- /dev/null +++ b/apps/api/src/canon/fingerprint.service.ts @@ -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; // 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 { + 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); + } +} diff --git a/apps/api/src/canon/illustration.service.ts b/apps/api/src/canon/illustration.service.ts new file mode 100644 index 0000000..3a0e66f --- /dev/null +++ b/apps/api/src/canon/illustration.service.ts @@ -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 } }); + } +} diff --git a/apps/api/src/canon/projection.service.ts b/apps/api/src/canon/projection.service.ts new file mode 100644 index 0000000..2af0635 --- /dev/null +++ b/apps/api/src/canon/projection.service.ts @@ -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 { + 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 { + 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); + } +} diff --git a/apps/api/src/canon/scene-import.service.ts b/apps/api/src/canon/scene-import.service.ts new file mode 100644 index 0000000..d7498cf --- /dev/null +++ b/apps/api/src/canon/scene-import.service.ts @@ -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 } }); + } +} diff --git a/apps/api/src/canon/timeline.service.ts b/apps/api/src/canon/timeline.service.ts new file mode 100644 index 0000000..8f9bef9 --- /dev/null +++ b/apps/api/src/canon/timeline.service.ts @@ -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 { + const facts = await this.prisma.client.sceneFact.findMany({ + where: { scene: { workId } }, + include: { scene: true }, + }); + + const groups = new Map(); + 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 } }); + } +} diff --git a/apps/api/src/canon/work-roster.service.ts b/apps/api/src/canon/work-roster.service.ts new file mode 100644 index 0000000..b9dea77 --- /dev/null +++ b/apps/api/src/canon/work-roster.service.ts @@ -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 { + 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 { + 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; + } +} diff --git a/packages/shared/src/domain/memory.ts b/packages/shared/src/domain/memory.ts index 049e0ef..a5df4cf 100644 --- a/packages/shared/src/domain/memory.ts +++ b/packages/shared/src/domain/memory.ts @@ -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; diff --git a/prisma/migrations/20260813083254_add_canon_research_pipeline/migration.sql b/prisma/migrations/20260813083254_add_canon_research_pipeline/migration.sql new file mode 100644 index 0000000..4bb8b74 --- /dev/null +++ b/prisma/migrations/20260813083254_add_canon_research_pipeline/migration.sql @@ -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"); diff --git a/prisma/migrations/20260813083500_canon_pipeline_projected_and_snapshot_scene/migration.sql b/prisma/migrations/20260813083500_canon_pipeline_projected_and_snapshot_scene/migration.sql new file mode 100644 index 0000000..ad05f45 --- /dev/null +++ b/prisma/migrations/20260813083500_canon_pipeline_projected_and_snapshot_scene/migration.sql @@ -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; diff --git a/prisma/migrations/20260813083600_canon_scene_source_type/migration.sql b/prisma/migrations/20260813083600_canon_scene_source_type/migration.sql new file mode 100644 index 0000000..7edcfb5 --- /dev/null +++ b/prisma/migrations/20260813083600_canon_scene_source_type/migration.sql @@ -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; diff --git a/prisma/migrations/20260813083807_canon_scene_interactions/migration.sql b/prisma/migrations/20260813083807_canon_scene_interactions/migration.sql new file mode 100644 index 0000000..368e374 --- /dev/null +++ b/prisma/migrations/20260813083807_canon_scene_interactions/migration.sql @@ -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 +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index df1fc07..0eaf8a3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,7 @@ enum MemorySource { INTERACTION // 互動 OFFLINE_GENERATED // 離線生成 SOURCE_EXTRACTION // 原作萃取 + CORRECTION // M-10 回溯修正:發現記錯了,以「想起來其實不是這樣」的新事件消化,不竄改既有的 INTERACTION 記憶 } // 情緒標籤類型:對應情緒子系統六種狀態(見 D 群組) @@ -124,15 +125,19 @@ model User { } model Work { - id String @id @default(cuid()) - title String - volumeProgress String? // 卷數進度 - worldview String? // 世界觀 - progressAnchor String? // 作品進度錨定點(M-9/N 群組使用;描述作品實際描寫過的時期) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + title String + volumeProgress String? // 卷數進度 + worldview String? // 世界觀 + progressAnchor String? // 作品進度錨定點(M-9/N 群組使用;描述作品實際描寫過的時期,人類可讀) + anchorStoryOrder Int @default(0) // M-9 錨定點的數值刀口:對應 Scene.storyOrder,之前是親身經歷,之後不存在 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - characters Character[] + 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") +} diff --git a/scripts/smoke/M.mjs b/scripts/smoke/M.mjs new file mode 100644 index 0000000..00a7c25 --- /dev/null +++ b/scripts/smoke/M.mjs @@ -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 } }); +} diff --git a/todo.md b/todo.md index 45f0203..e4e380e 100644 --- a/todo.md +++ b/todo.md @@ -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. 後日談模式