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 { 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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user