feat: 完成 M 群組 — 既有作品考據與輕小說章節管線

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-08-13 16:58:04 +08:00
co-authored by Claude Sonnet 5
parent cd948f0026
commit c66799ee7c
21 changed files with 1747 additions and 20 deletions
+2
View File
@@ -12,6 +12,7 @@ import { ScheduleModule } from "./schedule/schedule.module.js";
import { TaskModule } from "./task/task.module.js";
import { RoomModule } from "./room/room.module.js";
import { RomanceModule } from "./romance/romance.module.js";
import { CanonModule } from "./canon/canon.module.js";
@Module({
imports: [
@@ -27,6 +28,7 @@ import { RomanceModule } from "./romance/romance.module.js";
TaskModule,
RoomModule,
RomanceModule,
CanonModule,
],
controllers: [HealthController],
})
+54
View File
@@ -0,0 +1,54 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { ProjectionService } from "./projection.service.js";
import { WorkRosterService } from "./work-roster.service.js";
// M-9 錨定點與知識邊界:錨定點之前是角色的親身經歷、之後不存在。場景匯入時只寫入共用場景資料庫,
// 只有 storyOrder <= 錨定點的場景會被投影進角色記憶——移動錨定點(無論前進或後退)都要讓「已投影」的
// 場景集合與「storyOrder <= 錨定點」的場景集合重新同步。
@Injectable()
export class AnchorService {
constructor(
private readonly prisma: PrismaService,
private readonly projection: ProjectionService,
private readonly roster: WorkRosterService,
) {}
async setAnchor(workId: string, newStoryOrder: number): Promise<{ projected: number; unprojected: number }> {
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
const toProject = await this.prisma.client.scene.findMany({
where: { workId, storyOrder: { lte: newStoryOrder }, projected: false },
include: { participants: true },
});
for (const scene of toProject) {
await this.projection.projectScene(scene.id);
for (const presence of scene.participants) {
await this.roster.checkAndPromote(presence.characterId);
}
}
// 錨定點後退:她「不再知道」超出新錨定點的後段劇情——移除這些場景先前投影出的所有角色資料。
const toUnproject = await this.prisma.client.scene.findMany({
where: { workId, storyOrder: { gt: newStoryOrder }, projected: true },
});
for (const scene of toUnproject) {
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: scene.id } });
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: scene.id } });
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: scene.id } });
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId: scene.id } });
await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: false } });
}
await this.prisma.client.work.update({ where: { id: work.id }, data: { anchorStoryOrder: newStoryOrder } });
return { projected: toProject.length, unprojected: toUnproject.length };
}
// M-9 依錨定點取用「當時的她」的性格參數:取 atStoryOrder <= 錨定點裡最新的一筆快照。
async effectivePersonalitySnapshot(characterId: string, atStoryOrder: number) {
return this.prisma.client.personalityTraitSnapshot.findFirst({
where: { characterId, atStoryOrder: { lte: atStoryOrder } },
orderBy: { atStoryOrder: "desc" },
});
}
}
+137
View File
@@ -0,0 +1,137 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { FingerprintService } from "./fingerprint.service.js";
import { FINGERPRINT_WEIGHT, TURN_TAKING_WEIGHT, CONFIRM_THRESHOLD, FINGERPRINT_CONTRADICTION_SCORE } from "./constants.js";
interface CandidateScore {
characterId: string;
score: number;
fingerprintScore: number;
}
// M-5/M-6 歸屬時的多重證據與信心分數,以及隔離區機制:
// 明示標記(決定性)> 語言指紋(強)> 場景名冊(硬約束,篩掉不在場者)> 對話輪替(中)> 內容合理性(中,本實作暫略)。
// 低信心一律隔離,不寫入任何角色——寧可缺這句語料,不可污染角色資料。
@Injectable()
export class AttributionService {
constructor(
private readonly prisma: PrismaService,
private readonly fingerprint: FingerprintService,
) {}
// 冷啟動順序:先處理有明示標記的台詞(建立指紋基準),再回頭處理無標記對話。
async attributeScene(sceneId: string): Promise<void> {
const [scene, presences] = await Promise.all([
this.prisma.client.scene.findUniqueOrThrow({ where: { id: sceneId } }),
this.prisma.client.scenePresence.findMany({ where: { sceneId } }),
]);
const presentCharacterIds = presences.map((p) => p.characterId);
const lines = await this.prisma.client.sceneLine.findMany({
where: { sceneId, status: "PENDING" },
orderBy: { lineIndex: "asc" },
});
const markerLines = lines.filter((line) => line.hasExplicitMarker);
const unmarkedLines = lines.filter((line) => !line.hasExplicitMarker);
for (const line of markerLines) {
await this.confirm(line.id, line.markerCharacterId!, 1, scene.id, line.lineType);
}
for (const line of unmarkedLines) {
if (line.lineType === "NARRATION") {
// 敘述/新登場設定沒有「說話者」的歸屬問題,直接視為已確認(不需要指紋比對)。
await this.confirm(line.id, null, 1, scene.id, line.lineType);
continue;
}
await this.attributeLine(line.id, sceneId, presentCharacterIds);
}
}
private async attributeLine(lineId: string, sceneId: string, presentCharacterIds: string[]): Promise<void> {
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
if (presentCharacterIds.length === 0) {
await this.quarantine(lineId);
return;
}
const previousConfirmed = await this.prisma.client.sceneLine.findFirst({
where: { sceneId, lineIndex: { lt: line.lineIndex }, status: "CONFIRMED", speakerCharacterId: { not: null } },
orderBy: { lineIndex: "desc" },
});
const candidates: CandidateScore[] = [];
for (const characterId of presentCharacterIds) {
const fingerprintScore = await this.fingerprint.matchScore(line.rawText, characterId);
// 對話輪替:兩人場景中,上一句已確認的發言者若不是這位候選人,給予輪替加成(一問一答的結構)。
const turnTakingScore =
presentCharacterIds.length === 2 && previousConfirmed && previousConfirmed.speakerCharacterId !== characterId
? 1
: 0.5;
const score = fingerprintScore * FINGERPRINT_WEIGHT + turnTakingScore * TURN_TAKING_WEIGHT;
candidates.push({ characterId, score, fingerprintScore });
}
candidates.sort((a, b) => b.score - a.score);
const best = candidates[0];
if (best.score < CONFIRM_THRESHOLD || best.fingerprintScore < FINGERPRINT_CONTRADICTION_SCORE) {
await this.quarantine(lineId);
return;
}
await this.confirm(lineId, best.characterId, best.score, sceneId, line.lineType);
}
private async confirm(
lineId: string,
speakerCharacterId: string | null,
confidence: number,
sceneId: string,
lineType: string,
): Promise<void> {
await this.prisma.client.sceneLine.update({
where: { id: lineId },
data: { speakerCharacterId, confidence, status: "CONFIRMED" },
});
if (speakerCharacterId && lineType === "DIALOGUE") {
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
await this.prisma.client.languageCorpusEntry.create({
data: { characterId: speakerCharacterId, sceneId, text: line.rawText },
});
}
}
private async quarantine(lineId: string): Promise<void> {
await this.prisma.client.sceneLine.update({ where: { id: lineId }, data: { status: "QUARANTINED", confidence: 0 } });
}
// M-6 人工裁決:解除隔離,直接指定歸屬並寫入場景資料庫(含語料)。
async adjudicate(lineId: string, speakerCharacterId: string): Promise<void> {
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
await this.confirm(lineId, speakerCharacterId, 1, line.sceneId, line.lineType);
}
// M-6 指紋精煉:語料變多後,回頭重新嘗試隔離區裡的台詞,部分可望被釋放入庫。
async reattemptQuarantined(sceneId: string): Promise<number> {
const presences = await this.prisma.client.scenePresence.findMany({ where: { sceneId } });
const presentCharacterIds = presences.map((p) => p.characterId);
const quarantined = await this.prisma.client.sceneLine.findMany({
where: { sceneId, status: "QUARANTINED" },
orderBy: { lineIndex: "asc" },
});
let released = 0;
for (const line of quarantined) {
await this.prisma.client.sceneLine.update({ where: { id: line.id }, data: { status: "PENDING" } });
await this.attributeLine(line.id, sceneId, presentCharacterIds);
const updated = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: line.id } });
if (updated.status === "CONFIRMED") {
released += 1;
}
}
return released;
}
}
+113
View File
@@ -0,0 +1,113 @@
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import type { IllustrationType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
import { SceneImportService, type ImportSceneInput } from "./scene-import.service.js";
import { AttributionService } from "./attribution.service.js";
import { CorrectionService } from "./correction.service.js";
import { WorkRosterService } from "./work-roster.service.js";
import { IllustrationService } from "./illustration.service.js";
import { AnchorService } from "./anchor.service.js";
import { TimelineService } from "./timeline.service.js";
@Controller("canon")
export class CanonController {
constructor(
private readonly prisma: PrismaService,
private readonly sceneImport: SceneImportService,
private readonly attribution: AttributionService,
private readonly correction: CorrectionService,
private readonly roster: WorkRosterService,
private readonly illustration: IllustrationService,
private readonly anchor: AnchorService,
private readonly timeline: TimelineService,
) {}
@Post("scenes")
async importScene(@Body() body: ImportSceneInput) {
return this.sceneImport.importScene(body);
}
@Get("scenes/:sceneId")
async getScene(@Param("sceneId") sceneId: string) {
return this.prisma.client.scene.findUniqueOrThrow({
where: { id: sceneId },
include: { participants: true, lines: true, facts: true },
});
}
@Get("works/:workId/scenes")
async listScenes(@Param("workId") workId: string) {
return this.prisma.client.scene.findMany({ where: { workId }, orderBy: { storyOrder: "asc" } });
}
@Post("scene-lines/:lineId/adjudicate")
async adjudicate(@Param("lineId") lineId: string, @Body() body: { speakerCharacterId: string }) {
await this.attribution.adjudicate(lineId, body.speakerCharacterId);
return this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
}
@Post("scenes/:sceneId/reattempt-quarantined")
async reattemptQuarantined(@Param("sceneId") sceneId: string) {
const released = await this.attribution.reattemptQuarantined(sceneId);
return { released };
}
@Post("scene-lines/:lineId/correct")
async correctAttribution(@Param("lineId") lineId: string, @Body() body: { newSpeakerCharacterId: string; now?: string }) {
await this.correction.correctLineAttribution(lineId, body.newSpeakerCharacterId, body.now ? new Date(body.now) : undefined);
return this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
}
@Get("characters/:characterId/roster-progress")
async rosterProgress(@Param("characterId") characterId: string) {
return this.roster.getProgress(characterId);
}
@Post("illustrations")
async registerIllustration(
@Body()
body: {
workId: string;
volume: number;
page?: number;
type: IllustrationType;
sceneId?: string;
description: string;
costumeLabels?: { characterId: string; label: string }[];
poseLabels?: { characterId: string; label: string }[];
},
) {
return this.illustration.register(body);
}
@Get("scenes/:sceneId/illustrations")
async illustrationsForScene(@Param("sceneId") sceneId: string) {
return this.illustration.findByScene(sceneId);
}
@Get("characters/:characterId/costume-catalog")
async costumeCatalog(@Param("characterId") characterId: string) {
return this.illustration.listCostumeCatalog(characterId);
}
@Get("characters/:characterId/pose-vocabulary")
async poseVocabulary(@Param("characterId") characterId: string) {
return this.illustration.listPoseVocabulary(characterId);
}
@Post("works/:workId/anchor")
async setAnchor(@Param("workId") workId: string, @Body() body: { storyOrder: number }) {
return this.anchor.setAnchor(workId, body.storyOrder);
}
@Post("works/:workId/detect-contradictions")
async detectContradictions(@Param("workId") workId: string) {
const created = await this.timeline.detectContradictions(workId);
return { created };
}
@Get("works/:workId/contradictions")
async listContradictions(@Param("workId") workId: string) {
return this.timeline.listContradictions(workId);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { EmotionModule } from "../emotion/emotion.module.js";
import { FingerprintService } from "./fingerprint.service.js";
import { WorkRosterService } from "./work-roster.service.js";
import { IllustrationService } from "./illustration.service.js";
import { AttributionService } from "./attribution.service.js";
import { ProjectionService } from "./projection.service.js";
import { SceneImportService } from "./scene-import.service.js";
import { AnchorService } from "./anchor.service.js";
import { TimelineService } from "./timeline.service.js";
import { CorrectionService } from "./correction.service.js";
import { CanonController } from "./canon.controller.js";
@Module({
imports: [PrismaModule, EmotionModule],
controllers: [CanonController],
providers: [
FingerprintService,
WorkRosterService,
IllustrationService,
AttributionService,
ProjectionService,
SceneImportService,
AnchorService,
TimelineService,
CorrectionService,
],
exports: [
FingerprintService,
WorkRosterService,
IllustrationService,
AttributionService,
ProjectionService,
SceneImportService,
AnchorService,
TimelineService,
CorrectionService,
],
})
export class CanonModule {}
+23
View File
@@ -0,0 +1,23 @@
// M-2 建置門檻:新角色候補累積到這些條件才正式建置(路人只出現一次不值得建置)。
export const BUILD_THRESHOLD_SCENES = 3; // 出場場景數
export const BUILD_THRESHOLD_NAMED_LINES = 5; // 具名(已 CONFIRMED)台詞數
export const BUILD_THRESHOLD_INTERACTIONS = 1; // 與其他角色的實質互動(關係帳本事件)數
// M-5 歸屬信心分數:多重證據合成的權重與門檻。
export const FINGERPRINT_WEIGHT = 0.6;
export const TURN_TAKING_WEIGHT = 0.4;
export const CONFIRM_THRESHOLD = 0.55;
// M-6 指紋回檢:候選角色的指紋分數低於此值,即使總分達門檻仍視為與既有指紋矛盾,隔離不寫入。
export const FINGERPRINT_CONTRADICTION_SCORE = 0.15;
// M-5/M-6 語言指紋:學習自 LanguageCorpusEntry 的特徵詞頻率統計(人稱/語尾/口癖),非原型模板。
export const SIGNAL_TOKENS = ["呢~", "本大爺", "的說", "喵"];
// 語料達此則數以上,才認定「指紋已建立」(樣本太少時中性看待,不視為矛盾)。
export const FINGERPRINT_MIN_SAMPLES = 3;
// M-8 矛盾裁決優先序(數值越小優先權越高):小說原文 > 官方設定集 > 動畫改編。
export const SOURCE_PRIORITY: Record<string, number> = {
NOVEL: 1,
OFFICIAL_SETTEI: 2,
ANIME_ADAPTATION: 3,
};
+70
View File
@@ -0,0 +1,70 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { ProjectionService } from "./projection.service.js";
// M-7/M-10 歸屬修正與重新投影:修正場景資料庫只改一處,受影響角色的資料同步更新;
// 已上線角色發現歷史歸屬錯誤時,使用者互動記憶(source=INTERACTION)從不被觸碰——
// 只有由這個場景投影而來的資料(sourceSceneId 指到它)會被清掉重建。發現得晚的錯誤額外記一筆
// 「想起來其實不是這樣」的新事件,而不是假裝沒發生過。
@Injectable()
export class CorrectionService {
constructor(
private readonly prisma: PrismaService,
private readonly projection: ProjectionService,
) {}
async correctLineAttribution(lineId: string, newSpeakerCharacterId: string, now: Date = new Date()): Promise<void> {
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
const oldSpeakerCharacterId = line.speakerCharacterId;
if (oldSpeakerCharacterId === newSpeakerCharacterId) {
return;
}
const scene = await this.prisma.client.scene.findUniqueOrThrow({ where: { id: line.sceneId } });
const wasAlreadyProjected = scene.projected;
if (line.lineType === "DIALOGUE" && oldSpeakerCharacterId) {
await this.prisma.client.languageCorpusEntry.deleteMany({
where: { characterId: oldSpeakerCharacterId, sceneId: scene.id, text: line.rawText },
});
await this.prisma.client.languageCorpusEntry.create({
data: { characterId: newSpeakerCharacterId, sceneId: scene.id, text: line.rawText },
});
}
await this.prisma.client.sceneLine.update({
where: { id: lineId },
data: { speakerCharacterId: newSpeakerCharacterId, status: "CONFIRMED", confidence: 1 },
});
if (!wasAlreadyProjected) {
// 這場戲還沒被投影過(超前於錨定點,或當初就隔離未寫入),修正場景資料庫即可,沒有已投影的舊資料要清。
return;
}
await this.projection.reprojectScene(scene.id);
if (oldSpeakerCharacterId) {
await this.recordRealization(
oldSpeakerCharacterId,
`想起來,那句話其實不是我說的——是我記錯了。`,
now,
);
}
await this.recordRealization(newSpeakerCharacterId, `原來那句話其實是我說的,之前記混了。`, now);
}
private async recordRealization(characterId: string, content: string, now: Date): Promise<void> {
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content,
occurredAt: now,
emotionTag: "CALM",
emotionIntensity: 0.3,
source: "CORRECTION",
weight: 1,
},
});
}
}
+51
View File
@@ -0,0 +1,51 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { SIGNAL_TOKENS, FINGERPRINT_MIN_SAMPLES } from "./constants.js";
export type Fingerprint = Record<string, number>; // token -> 出現比例 0~1
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
// M-5/M-6 語言指紋:從已確認歸屬的台詞語料統計特徵詞頻率,供歸屬判斷與矛盾回檢使用。
// 前幾章的指紋樣本很少時,統計不具代表性——樣本量門檻由 matchScore 的呼叫端(AttributionService)處理。
@Injectable()
export class FingerprintService {
constructor(private readonly prisma: PrismaService) {}
async computeFingerprint(characterId: string): Promise<{ fingerprint: Fingerprint; sampleCount: number }> {
const entries = await this.prisma.client.languageCorpusEntry.findMany({ where: { characterId } });
const fingerprint: Fingerprint = {};
for (const token of SIGNAL_TOKENS) {
const hits = entries.filter((entry) => entry.text.includes(token)).length;
fingerprint[token] = entries.length > 0 ? hits / entries.length : 0;
}
return { fingerprint, sampleCount: entries.length };
}
// 分數解讀:0.5 為中性(文本沒有任何特徵詞,或指紋樣本不足以判斷);
// 命中角色慣用的特徵詞 → 加分;命中角色從不使用的特徵詞(指紋矛盾) → 顯著扣分。
async matchScore(text: string, characterId: string): Promise<number> {
const { fingerprint, sampleCount } = await this.computeFingerprint(characterId);
const hitTokens = SIGNAL_TOKENS.filter((token) => text.includes(token));
if (hitTokens.length === 0) {
return 0.5;
}
if (sampleCount < FINGERPRINT_MIN_SAMPLES) {
return 0.5; // 指紋尚未建立,中性看待,不因樣本不足誤判矛盾。
}
let score = 0.5;
for (const token of hitTokens) {
const freq = fingerprint[token];
if (freq > 0.3) {
score += 0.4;
} else if (freq > 0) {
score += 0.1;
} else {
score -= 0.4; // 明顯矛盾:這個角色的既有語料從未出現過這個特徵詞。
}
}
return clamp01(score);
}
}
@@ -0,0 +1,60 @@
import { Injectable } from "@nestjs/common";
import type { IllustrationType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
export interface RegisterIllustrationInput {
workId: string;
volume: number;
page?: number;
type: IllustrationType;
sceneId?: string;
description: string;
costumeLabels?: { characterId: string; label: string }[];
poseLabels?: { characterId: string; label: string }[];
}
// M-1 插圖索引:不儲存受版權保護的原圖,只登錄畫面描述;同時衍生服裝目錄與姿勢語彙表
// ——立繪服裝層之後只能從這個目錄取用,不可自創(見 O 群組立繪子系統會消費這裡的資料)。
@Injectable()
export class IllustrationService {
constructor(private readonly prisma: PrismaService) {}
async register(input: RegisterIllustrationInput) {
const illustration = await this.prisma.client.illustration.create({
data: {
workId: input.workId,
volume: input.volume,
page: input.page,
type: input.type,
sceneId: input.sceneId,
description: input.description,
},
});
for (const costume of input.costumeLabels ?? []) {
await this.prisma.client.costumeCatalogEntry.create({
data: { characterId: costume.characterId, illustrationId: illustration.id, label: costume.label },
});
}
for (const pose of input.poseLabels ?? []) {
await this.prisma.client.poseVocabularyEntry.create({
data: { characterId: pose.characterId, illustrationId: illustration.id, label: pose.label },
});
}
return illustration;
}
// M-1 驗收:可由事件(場景)查到對應插圖。
async findByScene(sceneId: string) {
return this.prisma.client.illustration.findMany({ where: { sceneId } });
}
async listCostumeCatalog(characterId: string) {
return this.prisma.client.costumeCatalogEntry.findMany({ where: { characterId } });
}
async listPoseVocabulary(characterId: string) {
return this.prisma.client.poseVocabularyEntry.findMany({ where: { characterId } });
}
}
+105
View File
@@ -0,0 +1,105 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
import { HIGH_EMOTION_THRESHOLD } from "../memory/constants.js";
const CANON_EPOCH_MS = new Date("2000-01-01T00:00:00.000Z").getTime();
const DAY_MS = 24 * 60 * 60 * 1000;
// 場景資料庫只有「年表排序鍵」(storyOrder),沒有真實日期;投影成 EpisodicMemory 時需要一個 DateTime,
// 用固定虛擬曆元 + storyOrder 天數換算即可——只要求相對順序正確,不代表任何真實日期。
function storyOrderToDate(storyOrder: number): Date {
return new Date(CANON_EPOCH_MS + storyOrder * DAY_MS);
}
// M-4/M-7 多角色投影:共用場景資料庫只處理一次,各角色資料是投影出來的——修正場景資料庫一處,
// 重新投影後所有受影響角色同步更新。M-9 知識邊界:只有 storyOrder <= 錨定點的場景才會被投影。
@Injectable()
export class ProjectionService {
constructor(
private readonly prisma: PrismaService,
private readonly tagger: RuleBasedEmotionTagger,
) {}
async projectScene(sceneId: string): Promise<void> {
const scene = await this.prisma.client.scene.findUniqueOrThrow({
where: { id: sceneId },
include: { participants: true, lines: true, interactions: true },
});
const presentCharacterIds = scene.participants.map((p) => p.characterId);
const occurredAt = storyOrderToDate(scene.storyOrder);
// 1) 事件 → 情節記憶:她不在場的不記得——只有在場角色才會拿到這場戲的情節記憶。
const signal = this.tagger.tag({ text: scene.summary });
const hasMonologue = scene.lines.some((line) => line.lineType === "MONOLOGUE" && line.status === "CONFIRMED");
const isHighEmotion = signal.intensity >= HIGH_EMOTION_THRESHOLD;
// M-8 情緒權重評分:文本篇幅與心理描寫深度是天然的情緒權重指標。
const weight = (isHighEmotion ? 1.5 : 0.8) * (hasMonologue ? 1.4 : 1) * (1 + Math.min(1, scene.rawText.length / 500));
for (const characterId of presentCharacterIds) {
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content: scene.summary,
occurredAt,
emotionTag: signal.tag,
emotionIntensity: signal.intensity || 0.3,
source: "SOURCE_EXTRACTION",
weight,
sourceSceneId: scene.id,
},
});
}
// 2) 台詞 → 語言風格語料:已由 AttributionService 在確認歸屬的當下寫入 LanguageCorpusEntry,這裡不重複處理。
// 3) 內心獨白/心理描寫 → 性格參數證據。
const monologueLines = scene.lines.filter(
(line) => line.lineType === "MONOLOGUE" && line.status === "CONFIRMED" && line.speakerCharacterId,
);
for (const line of monologueLines) {
await this.prisma.client.personalityTraitSnapshot.create({
data: {
characterId: line.speakerCharacterId!,
sourceSceneId: scene.id,
atStoryOrder: scene.storyOrder,
note: line.rawText,
},
});
}
// 4) 與他人的互動 → 關係帳本(角色對角色):中立記錄在此刻才真正套用到雙方視角。
for (const interaction of scene.interactions) {
await this.prisma.client.characterRelationshipEventLog.create({
data: {
characterId: interaction.characterId,
otherCharacterId: interaction.otherCharacterId,
sceneId: scene.id,
delta: interaction.delta,
description: interaction.description,
},
});
}
// 5) 新登場設定 → 語意記憶:NARRATION 為場景中立敘述,在場角色都「知道」這件事。
const narrationLines = scene.lines.filter((line) => line.lineType === "NARRATION" && line.status === "CONFIRMED");
for (const line of narrationLines) {
for (const characterId of presentCharacterIds) {
await this.prisma.client.semanticMemory.create({
data: { characterId, fact: line.rawText, about: "世界觀", sourceSceneId: scene.id },
});
}
}
await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: true } });
}
// M-7 重新投影:修正場景資料庫後,先移除這個場景先前投影出的所有資料,再重新投影一次。
// 只清「由這個場景投影而來」的紀錄(sourceSceneId 指到它)——使用者互動記憶從不設這個欄位,永遠不受影響。
async reprojectScene(sceneId: string): Promise<void> {
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: sceneId } });
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: sceneId } });
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: sceneId } });
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId } });
await this.projectScene(sceneId);
}
}
+117
View File
@@ -0,0 +1,117 @@
import { Injectable } from "@nestjs/common";
import type { SceneLineType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
import { AttributionService } from "./attribution.service.js";
import { ProjectionService } from "./projection.service.js";
import { WorkRosterService } from "./work-roster.service.js";
export interface ImportLineInput {
lineType: SceneLineType;
rawText: string;
hasExplicitMarker?: boolean;
markerCharacterId?: string;
}
export interface ImportInteractionInput {
characterId: string;
otherCharacterId: string;
delta: number;
description: string;
}
export interface ImportFactInput {
eventLabel: string;
factKey: string;
factValue: string;
}
export interface ImportSceneInput {
workId: string;
volume: number;
chapterLabel: string;
storyOrder: number;
sourceType?: string;
summary: string;
rawText: string;
participantCharacterIds: string[];
lines: ImportLineInput[];
interactions?: ImportInteractionInput[];
facts?: ImportFactInput[];
}
// M-3 章節匯入與場景切分:以場景為單位寫入共用場景資料庫(角色中立的客觀記錄)。
// 場景本身的切分(章節文本 → 一個個場景)由呼叫端(人工或前置流程)完成後以結構化輸入送進來——
// 這裡不做自由文本的自動場景邊界偵測,理由同 F-7 MockProvider:對應的真實 NLP 能力屬於之後才會補的範疇。
@Injectable()
export class SceneImportService {
constructor(
private readonly prisma: PrismaService,
private readonly attribution: AttributionService,
private readonly projection: ProjectionService,
private readonly roster: WorkRosterService,
) {}
async importScene(input: ImportSceneInput) {
const scene = await this.prisma.client.scene.create({
data: {
workId: input.workId,
volume: input.volume,
chapterLabel: input.chapterLabel,
storyOrder: input.storyOrder,
sourceType: input.sourceType ?? "NOVEL",
summary: input.summary,
rawText: input.rawText,
},
});
for (const characterId of input.participantCharacterIds) {
await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } });
}
for (const [lineIndex, line] of input.lines.entries()) {
await this.prisma.client.sceneLine.create({
data: {
sceneId: scene.id,
lineIndex,
lineType: line.lineType,
rawText: line.rawText,
hasExplicitMarker: line.hasExplicitMarker ?? false,
markerCharacterId: line.markerCharacterId,
},
});
}
for (const interaction of input.interactions ?? []) {
await this.prisma.client.sceneInteraction.create({
data: {
sceneId: scene.id,
characterId: interaction.characterId,
otherCharacterId: interaction.otherCharacterId,
delta: interaction.delta,
description: interaction.description,
},
});
}
for (const fact of input.facts ?? []) {
await this.prisma.client.sceneFact.create({
data: { sceneId: scene.id, eventLabel: fact.eventLabel, factKey: fact.factKey, factValue: fact.factValue },
});
}
await this.attribution.attributeScene(scene.id);
// M-9 知識邊界:只有在錨定點之內的場景才立刻投影;超前的場景先留在場景資料庫,等錨定點推進時再載入。
// 建置門檻的「實質互動」次數只在投影後才算數(互動要先套用到關係帳本才算數),因此門檻檢查必須排在投影之後。
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: input.workId } });
if (scene.storyOrder <= work.anchorStoryOrder) {
await this.projection.projectScene(scene.id);
}
for (const characterId of input.participantCharacterIds) {
await this.roster.checkAndPromote(characterId);
}
return this.prisma.client.scene.findUniqueOrThrow({ where: { id: scene.id }, include: { participants: true, lines: true } });
}
}
+67
View File
@@ -0,0 +1,67 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { SOURCE_PRIORITY } from "./constants.js";
// M-8 跨章節整合:共用年表排序(Scene.storyOrder 本身即年表鍵,不需要另外的服務排序)、
// 矛盾偵測與裁決優先序(小說原文 > 官方設定集 > 動畫改編),無法裁決者標記待人工確認。
@Injectable()
export class TimelineService {
constructor(private readonly prisma: PrismaService) {}
// 掃描同一事件標籤下、同一事實鍵卻不同事實值的場景——這是故意置入矛盾最容易驗證的形式。
async detectContradictions(workId: string): Promise<number> {
const facts = await this.prisma.client.sceneFact.findMany({
where: { scene: { workId } },
include: { scene: true },
});
const groups = new Map<string, typeof facts>();
for (const fact of facts) {
const key = `${fact.eventLabel}::${fact.factKey}`;
const group = groups.get(key) ?? [];
group.push(fact);
groups.set(key, group);
}
let created = 0;
for (const [key, group] of groups) {
const distinctValues = new Set(group.map((fact) => fact.factValue));
if (distinctValues.size <= 1) {
continue;
}
const [eventLabel, factKey] = key.split("::");
const existing = await this.prisma.client.sceneContradiction.findFirst({ where: { workId, eventLabel, factKey } });
if (existing) {
continue;
}
const sorted = [...group].sort(
(a, b) => (SOURCE_PRIORITY[a.scene.sourceType] ?? 99) - (SOURCE_PRIORITY[b.scene.sourceType] ?? 99),
);
const best = sorted[0];
const runnerUpDifferentValue = sorted.find((fact) => fact.factValue !== best.factValue);
const canAutoResolve =
runnerUpDifferentValue !== undefined &&
(SOURCE_PRIORITY[best.scene.sourceType] ?? 99) < (SOURCE_PRIORITY[runnerUpDifferentValue.scene.sourceType] ?? 99);
await this.prisma.client.sceneContradiction.create({
data: {
workId,
eventLabel,
factKey,
description: `「${eventLabel}」的「${factKey}」出現不同描述:${[...distinctValues].join(" vs ")}`,
status: canAutoResolve ? "RESOLVED" : "PENDING_HUMAN_REVIEW",
resolution: canAutoResolve
? `依裁決優先序(小說原文 > 官方設定集 > 動畫改編)採用「${best.factValue}」(來源:${best.scene.sourceType})`
: null,
},
});
created += 1;
}
return created;
}
async listContradictions(workId: string) {
return this.prisma.client.sceneContradiction.findMany({ where: { workId } });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { BUILD_THRESHOLD_SCENES, BUILD_THRESHOLD_NAMED_LINES, BUILD_THRESHOLD_INTERACTIONS } from "./constants.js";
export interface RosterProgress {
characterId: string;
sceneCount: number;
namedLineCount: number;
interactionCount: number;
meetsThreshold: boolean;
}
// M-2 作品名冊與建置門檻:新角色先進候補狀態,資料照常在場景資料庫累積,達門檻才正式建置。
// 累積量一律即時查詢(場景/台詞/互動紀錄本身就是唯一真相),不維護額外的計數快取,避免同步漂移。
@Injectable()
export class WorkRosterService {
constructor(private readonly prisma: PrismaService) {}
async getProgress(characterId: string): Promise<RosterProgress> {
const [sceneCount, namedLineCount, interactionCount] = await Promise.all([
this.prisma.client.scenePresence.count({ where: { characterId } }),
this.prisma.client.sceneLine.count({ where: { speakerCharacterId: characterId, status: "CONFIRMED" } }),
this.prisma.client.characterRelationshipEventLog.count({ where: { characterId } }),
]);
return {
characterId,
sceneCount,
namedLineCount,
interactionCount,
meetsThreshold:
sceneCount >= BUILD_THRESHOLD_SCENES &&
namedLineCount >= BUILD_THRESHOLD_NAMED_LINES &&
interactionCount >= BUILD_THRESHOLD_INTERACTIONS,
};
}
// 匯入場景後對每個候補角色呼叫:達門檻就自動升級為正式建置,之後的匯入走「強化路徑」(不需要特別的程式碼分支
// ——強化與候補累積本來就是同一組寫入邏輯,差別只在 buildStatus 這個查詢結果)。
async checkAndPromote(characterId: string): Promise<boolean> {
const character = await this.prisma.client.character.findUnique({ where: { id: characterId } });
if (!character || character.buildStatus === "BUILT") {
return false;
}
const progress = await this.getProgress(characterId);
if (!progress.meetsThreshold) {
return false;
}
await this.prisma.client.character.update({ where: { id: characterId }, data: { buildStatus: "BUILT" } });
return true;
}
}