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:
co-authored by
Claude Sonnet 5
parent
cd948f0026
commit
c66799ee7c
@@ -12,6 +12,7 @@ import { ScheduleModule } from "./schedule/schedule.module.js";
|
|||||||
import { TaskModule } from "./task/task.module.js";
|
import { TaskModule } from "./task/task.module.js";
|
||||||
import { RoomModule } from "./room/room.module.js";
|
import { RoomModule } from "./room/room.module.js";
|
||||||
import { RomanceModule } from "./romance/romance.module.js";
|
import { RomanceModule } from "./romance/romance.module.js";
|
||||||
|
import { CanonModule } from "./canon/canon.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -27,6 +28,7 @@ import { RomanceModule } from "./romance/romance.module.js";
|
|||||||
TaskModule,
|
TaskModule,
|
||||||
RoomModule,
|
RoomModule,
|
||||||
RomanceModule,
|
RomanceModule,
|
||||||
|
CanonModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
// 情緒標籤類型:對應情緒子系統六種狀態(見 D 群組)
|
// 情緒標籤類型:對應情緒子系統六種狀態(見 D 群組)
|
||||||
export type EmotionTag = "CALM" | "JOY" | "SAD" | "ALERT" | "SHY" | "GRUMPY";
|
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 {
|
export interface EpisodicMemory {
|
||||||
id: string;
|
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");
|
||||||
+35
@@ -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
|
||||||
|
);
|
||||||
+245
-8
@@ -24,6 +24,7 @@ enum MemorySource {
|
|||||||
INTERACTION // 互動
|
INTERACTION // 互動
|
||||||
OFFLINE_GENERATED // 離線生成
|
OFFLINE_GENERATED // 離線生成
|
||||||
SOURCE_EXTRACTION // 原作萃取
|
SOURCE_EXTRACTION // 原作萃取
|
||||||
|
CORRECTION // M-10 回溯修正:發現記錯了,以「想起來其實不是這樣」的新事件消化,不竄改既有的 INTERACTION 記憶
|
||||||
}
|
}
|
||||||
|
|
||||||
// 情緒標籤類型:對應情緒子系統六種狀態(見 D 群組)
|
// 情緒標籤類型:對應情緒子系統六種狀態(見 D 群組)
|
||||||
@@ -124,15 +125,19 @@ model User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Work {
|
model Work {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
volumeProgress String? // 卷數進度
|
volumeProgress String? // 卷數進度
|
||||||
worldview String? // 世界觀
|
worldview String? // 世界觀
|
||||||
progressAnchor String? // 作品進度錨定點(M-9/N 群組使用;描述作品實際描寫過的時期)
|
progressAnchor String? // 作品進度錨定點(M-9/N 群組使用;描述作品實際描寫過的時期,人類可讀)
|
||||||
createdAt DateTime @default(now())
|
anchorStoryOrder Int @default(0) // M-9 錨定點的數值刀口:對應 Scene.storyOrder,之前是親身經歷,之後不存在
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
characters Character[]
|
characters Character[]
|
||||||
|
scenes Scene[]
|
||||||
|
illustrations Illustration[]
|
||||||
|
contradictions SceneContradiction[]
|
||||||
|
|
||||||
@@map("works")
|
@@map("works")
|
||||||
}
|
}
|
||||||
@@ -174,6 +179,14 @@ model Character {
|
|||||||
ageEvidence CharacterAgeEvidence[]
|
ageEvidence CharacterAgeEvidence[]
|
||||||
romanceEventLogs RomanceEventLog[]
|
romanceEventLogs RomanceEventLog[]
|
||||||
adultContentAttempts AdultContentAttemptLog[]
|
adultContentAttempts AdultContentAttemptLog[]
|
||||||
|
scenePresences ScenePresence[]
|
||||||
|
spokenLines SceneLine[]
|
||||||
|
costumeCatalogEntries CostumeCatalogEntry[]
|
||||||
|
poseVocabularyEntries PoseVocabularyEntry[]
|
||||||
|
languageCorpusEntries LanguageCorpusEntry[]
|
||||||
|
personalityTraitSnapshots PersonalityTraitSnapshot[]
|
||||||
|
relationshipEventLogsFrom CharacterRelationshipEventLog[] @relation("EventLogFrom")
|
||||||
|
relationshipEventLogsTo CharacterRelationshipEventLog[] @relation("EventLogTo")
|
||||||
|
|
||||||
@@map("characters")
|
@@map("characters")
|
||||||
}
|
}
|
||||||
@@ -325,6 +338,10 @@ model EpisodicMemory {
|
|||||||
relatedUserId String? // 這段記憶與哪位使用者相關(E-5 關係加權檢索用)
|
relatedUserId String? // 這段記憶與哪位使用者相關(E-5 關係加權檢索用)
|
||||||
relatedUser User? @relation(fields: [relatedUserId], references: [id], onDelete: SetNull)
|
relatedUser User? @relation(fields: [relatedUserId], references: [id], onDelete: SetNull)
|
||||||
isPrivate Boolean @default(false) // K-3 隱私邊界:一對一聊過的私密內容,群聊中不主動洩漏(口風不緊原型除外)
|
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())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@map("episodic_memories")
|
@@map("episodic_memories")
|
||||||
@@ -336,6 +353,8 @@ model SemanticMemory {
|
|||||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
fact String // 去情境化事實
|
fact String // 去情境化事實
|
||||||
about String // 對象(關於誰/什麼)
|
about String // 對象(關於誰/什麼)
|
||||||
|
sourceSceneId String? // M-4/M-7 可追溯性:由哪個場景萃取而來(新登場設定)
|
||||||
|
sourceScene Scene? @relation(fields: [sourceSceneId], references: [id], onDelete: SetNull)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@map("semantic_memories")
|
@@map("semantic_memories")
|
||||||
@@ -457,3 +476,221 @@ model ProactiveCareLog {
|
|||||||
|
|
||||||
@@map("proactive_care_logs")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -328,17 +328,28 @@ flowchart TB
|
|||||||
|
|
||||||
### M. 既有作品考據與輕小說章節管線
|
### M. 既有作品考據與輕小說章節管線
|
||||||
|
|
||||||
- [ ] **M-1 插圖索引(S)**:登錄插圖(卷數/頁碼/對應場景/類型=封面/彩頁/黑白),並衍生服裝目錄與姿勢語彙表;立繪服裝層只能從目錄取用、不自創。驗收:可由事件查到對應插圖,服裝目錄可列出。依據:§插圖作為立繪參考、§插圖與文本的交叉索引。
|
- [x] **M-1 插圖索引(S)**:登錄插圖(卷數/頁碼/對應場景/類型=封面/彩頁/黑白),並衍生服裝目錄與姿勢語彙表;立繪服裝層只能從目錄取用、不自創。驗收:可由事件查到對應插圖,服裝目錄可列出。依據:§插圖作為立繪參考、§插圖與文本的交叉索引。
|
||||||
- [ ] **M-2 作品名冊與建置門檻(S)**:角色名冊登錄正式名/暱稱/他人稱呼,新角色先進候補狀態,達門檻(出場場景數、具名台詞數、與主要角色實質互動)才正式建置。驗收:路人角色維持候補、主要角色達標後自動建置。依據:§章節匯入流程:強化或新建、§建置門檻。
|
- [x] **M-2 作品名冊與建置門檻(S)**:角色名冊登錄正式名/暱稱/他人稱呼,新角色先進候補狀態,達門檻(出場場景數、具名台詞數、與主要角色實質互動)才正式建置。驗收:路人角色維持候補、主要角色達標後自動建置。依據:§章節匯入流程:強化或新建、§建置門檻。
|
||||||
- [ ] **M-3 章節匯入與場景切分(M)**:匯入章節文本,以場景為單位切分並寫入**共用場景資料庫**(客觀記錄:在場者、誰說了哪句、誰做了什麼,不含主觀詮釋)。驗收:以自製測試文本匯入後可列出場景與在場者。依據:§共用場景資料庫:處理一次,各自投影。註記:測試文本不得使用受版權保護的原文(需人工確認來源)。
|
- [x] **M-3 章節匯入與場景切分(M)**:匯入章節文本,以場景為單位切分並寫入**共用場景資料庫**(客觀記錄:在場者、誰說了哪句、誰做了什麼,不含主觀詮釋)。驗收:以自製測試文本匯入後可列出場景與在場者。依據:§共用場景資料庫:處理一次,各自投影。註記:測試文本不得使用受版權保護的原文(需人工確認來源)。
|
||||||
- [ ] **M-4 逐場景萃取(M)**:萃取事件、目標角色台詞、內心獨白/心理描寫、與他人的互動、新登場設定五類產物。驗收:五類產物各自入庫且可追溯到來源場景。依據:§逐章處理管線。
|
- [x] **M-4 逐場景萃取(M)**:萃取事件、目標角色台詞、內心獨白/心理描寫、與他人的互動、新登場設定五類產物。驗收:五類產物各自入庫且可追溯到來源場景。依據:§逐章處理管線。
|
||||||
- [ ] **M-5 歸屬信心分數(M)**:每句台詞/動作的歸屬附信心分數,證據強度依序為 明示標記(決定性)>語言指紋(強)>場景名冊(硬約束:不在場者不可能說話)>對話輪替(中)>內容合理性(中)。驗收:無標記對話能給出帶信心分數的歸屬。依據:§防線一:歸屬時的多重證據與信心分數。
|
- [x] **M-5 歸屬信心分數(M)**:每句台詞/動作的歸屬附信心分數,證據強度依序為 明示標記(決定性)>語言指紋(強)>場景名冊(硬約束:不在場者不可能說話)>對話輪替(中)>內容合理性(中)。驗收:無標記對話能給出帶信心分數的歸屬。依據:§防線一:歸屬時的多重證據與信心分數。
|
||||||
- [ ] **M-6 隔離區與指紋冷啟動(M)**:信心低於門檻或與既有語言指紋矛盾者一律進隔離區、不寫入任何角色,累積成待裁決清單;前幾章先用有明示標記的台詞建立指紋基準再回頭處理無標記對話。驗收:低信心台詞不污染角色語料,裁決後可釋放入庫。依據:§防線二:寫入前的交叉驗證。
|
- [x] **M-6 隔離區與指紋冷啟動(M)**:信心低於門檻或與既有語言指紋矛盾者一律進隔離區、不寫入任何角色,累積成待裁決清單;前幾章先用有明示標記的台詞建立指紋基準再回頭處理無標記對話。驗收:低信心台詞不污染角色語料,裁決後可釋放入庫。依據:§防線二:寫入前的交叉驗證。
|
||||||
- [ ] **M-7 多角色投影(M)**:由場景資料庫投影出各角色的情節記憶(以她的視角改寫、她不在場的不記得、她誤解的按她以為的版本存)、語料與關係帳本事件;修正只改場景資料庫一處後重新投影。驗收:修正一句台詞歸屬後,受影響的多個角色資料同步更新。依據:§共用場景資料庫、§各類萃取物的處理規則。
|
- [x] **M-7 多角色投影(M)**:由場景資料庫投影出各角色的情節記憶(以她的視角改寫、她不在場的不記得、她誤解的按她以為的版本存)、語料與關係帳本事件;修正只改場景資料庫一處後重新投影。驗收:修正一句台詞歸屬後,受影響的多個角色資料同步更新。依據:§共用場景資料庫、§各類萃取物的處理規則。
|
||||||
- [ ] **M-8 跨章節整合(M)**:共用年表排序、依文本篇幅與心理描寫深度評情緒權重、矛盾偵測與裁決優先序(小說原文 > 官方設定集 > 動畫改編),無法裁決標「待人工確認」。驗收:故意置入的矛盾被偵測並標記。依據:§跨章節整合的關鍵。
|
- [x] **M-8 跨章節整合(M)**:共用年表排序、依文本篇幅與心理描寫深度評情緒權重、矛盾偵測與裁決優先序(小說原文 > 官方設定集 > 動畫改編),無法裁決標「待人工確認」。驗收:故意置入的矛盾被偵測並標記。依據:§跨章節整合的關鍵。
|
||||||
- [ ] **M-9 錨定點與知識邊界(M)**:作品進度錨定點統一於作品層級,錨定點之前為角色的親身經歷、之後不存在;性格參數為帶時間戳的序列,依錨定點取值;同作品角色共用錨定點但年齡逐角色判定。驗收:錨定點前移後,角色不再知道後段劇情。依據:§跨章節整合「時間點錨定」、§錨定時間點的統一。
|
- [x] **M-9 錨定點與知識邊界(M)**:作品進度錨定點統一於作品層級,錨定點之前為角色的親身經歷、之後不存在;性格參數為帶時間戳的序列,依錨定點取值;同作品角色共用錨定點但年齡逐角色判定。驗收:錨定點前移後,角色不再知道後段劇情。依據:§跨章節整合「時間點錨定」、§錨定時間點的統一。
|
||||||
- [ ] **M-10 增量補全與回溯修正(M)**:新卷匯入時追加至年表尾端(錨定點之後者存為未啟用);新資訊可回溯修正舊參數;已上線角色發現歷史歸屬錯誤時,修正場景資料庫 → 重新投影 → 重算指紋與關係帳本,但**與使用者已發生的互動記憶不回溯竄改**,改以「想起來其實不是這樣」的新互動事件消化。驗收:修正後使用者互動記錄完整保留。依據:§增量補全、§誤判發現得晚怎麼辦。
|
- [x] **M-10 增量補全與回溯修正(M)**:新卷匯入時追加至年表尾端(錨定點之後者存為未啟用);新資訊可回溯修正舊參數;已上線角色發現歷史歸屬錯誤時,修正場景資料庫 → 重新投影 → 重算指紋與關係帳本,但**與使用者已發生的互動記憶不回溯竄改**,改以「想起來其實不是這樣」的新互動事件消化。驗收:修正後使用者互動記錄完整保留。依據:§增量補全、§誤判發現得晚怎麼辦。
|
||||||
- [ ] **M-V 階段驗證(S)**:`npm run restart && npm run smoke -- M`(M.mjs:匯入測試文本 → 場景記錄 → 兩角色投影 → 客觀事實一致性檢核通過、主觀詮釋差異被允許)。
|
- [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. 後日談模式
|
### N. 後日談模式
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user