@musnows/scriverse 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -21
- package/README.en.md +8 -0
- package/README.md +8 -0
- package/dist/ai.js +1259 -163
- package/dist/ai.js.map +1 -1
- package/dist/app.js +138 -19
- package/dist/app.js.map +1 -1
- package/dist/database.js +340 -0
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +171 -56
- package/dist/public/display-labels.js +1 -0
- package/dist/public/index.html +13 -7
- package/dist/public/styles.css +74 -6
- package/dist/relationship-search.js +196 -0
- package/dist/relationship-search.js.map +1 -0
- package/dist/store.js +909 -13
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +23 -6
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
package/dist/ai.js
CHANGED
|
@@ -4,6 +4,7 @@ import { logger, sanitizeError } from "./logger.js";
|
|
|
4
4
|
import { paginated, paginationSql } from "./pagination.js";
|
|
5
5
|
import { currentRequestActor } from "./request-context.js";
|
|
6
6
|
import { fetchSafeAiEndpoint } from "./security.js";
|
|
7
|
+
import { RELATIONSHIP_SEARCH_POLICY_VERSION, RelationshipApproximateMatchLimitError, findApproximateNameMatchesChunked, ftsPhrase, normalizeRelationshipSearchText, relationshipCharacterTokenText, relationshipCharacterTokens, relationshipPinyinTokenText, relationshipPinyinTokens } from "./relationship-search.js";
|
|
7
8
|
import { clamp, id, json, maskSecret, normalizeBaseUrl, now } from "./utils.js";
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
export function aiErrorForLog(error) {
|
|
@@ -19,6 +20,11 @@ export function aiErrorForLog(error) {
|
|
|
19
20
|
const allowedParameters = new Set(["temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty", "seed"]);
|
|
20
21
|
const DEFAULT_MAX_TOKENS = 32_000;
|
|
21
22
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
23
|
+
const RELATIONSHIP_MAX_FUZZY_REFERENCES = 32;
|
|
24
|
+
const RELATIONSHIP_MAX_FUZZY_SOURCES = 200;
|
|
25
|
+
const RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS = 4_000_000;
|
|
26
|
+
const RELATIONSHIP_MAX_FUZZY_MATCHES = 600;
|
|
27
|
+
const RELATIONSHIP_MAX_SOURCE_MATCHES = 256;
|
|
22
28
|
function isGeminiProviderOrModel(provider, model) {
|
|
23
29
|
const endpoint = stringValue(provider, "base_url").toLowerCase();
|
|
24
30
|
const modelId = stringValue(model, "model_id").toLowerCase();
|
|
@@ -33,65 +39,44 @@ function thinkingParameters(provider, model) {
|
|
|
33
39
|
return { thinking: { type: boolValue(model, "thinking_enabled") ? "enabled" : "disabled" } };
|
|
34
40
|
}
|
|
35
41
|
const AGENT_TOOL_IDS = ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"];
|
|
36
|
-
const TASK_TRACE_PREVIEW_CHARACTER_LIMIT = 3_000;
|
|
37
42
|
function traceRecord(value) {
|
|
38
43
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
39
44
|
}
|
|
40
|
-
function
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
45
|
+
function taskTraceSourceRefs(initialMessages, rounds) {
|
|
46
|
+
const refs = [];
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const add = (type, title) => {
|
|
49
|
+
const normalizedTitle = title.trim();
|
|
50
|
+
if (!normalizedTitle)
|
|
51
|
+
return;
|
|
52
|
+
const key = `${type}|${normalizedTitle}`;
|
|
53
|
+
if (seen.has(key))
|
|
54
|
+
return;
|
|
55
|
+
seen.add(key);
|
|
56
|
+
refs.push({ type, title: normalizedTitle });
|
|
57
|
+
};
|
|
58
|
+
const roundMessages = rounds.flatMap((value) => {
|
|
59
|
+
const request = traceRecord(traceRecord(value).request);
|
|
60
|
+
return Array.isArray(request.messages) ? request.messages : [];
|
|
61
|
+
});
|
|
62
|
+
for (const message of [...initialMessages, ...roundMessages]) {
|
|
63
|
+
const content = String(traceRecord(message).content ?? "");
|
|
64
|
+
for (const match of content.matchAll(/<CHAPTER\b[^>]*\btitle="([^"]+)"[^>]*>/gu))
|
|
65
|
+
add("chapter", match[1] ?? "");
|
|
66
|
+
for (const match of content.matchAll(/<SETTING\b[^>]*\btitle="([^"]+)"[^>]*>/gu))
|
|
67
|
+
add("setting", match[1] ?? "");
|
|
68
|
+
for (const match of content.matchAll(/\[(?:# )?([^\]\n]+?)\s*\|\s*版本\s+\d+\]/gu)) {
|
|
69
|
+
const title = (match[1] ?? "").split(/\s+\/\s+/u).at(-1) ?? "";
|
|
70
|
+
add("chapter", title);
|
|
58
71
|
}
|
|
59
|
-
|
|
72
|
+
for (const match of content.matchAll(/(?:当前章节|所在章节):([^\n|]+?)\s*\|\s*版本\s+\d+/gu))
|
|
73
|
+
add("chapter", match[1] ?? "");
|
|
74
|
+
for (const match of content.matchAll(/"chapterTitle"\s*:\s*"([^"]+)"/gu))
|
|
75
|
+
add("chapter", match[1] ?? "");
|
|
76
|
+
for (const match of content.matchAll(/"chapterId"\s*:\s*"[^"]+"[\s\S]{0,240}?"title"\s*:\s*"([^"]+)"/gu))
|
|
77
|
+
add("chapter", match[1] ?? "");
|
|
60
78
|
}
|
|
61
|
-
return
|
|
62
|
-
messages: records.map((message, index) => {
|
|
63
|
-
const content = contents[index];
|
|
64
|
-
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
65
|
-
return {
|
|
66
|
-
role: typeof message.role === "string" ? message.role : "user",
|
|
67
|
-
content: content.slice(0, allocations[index]),
|
|
68
|
-
contentChars: content.length,
|
|
69
|
-
contentTruncated: allocations[index] < content.length,
|
|
70
|
-
toolCallCount: toolCalls.length,
|
|
71
|
-
...(typeof message.tool_call_id === "string" ? { tool_call_id: message.tool_call_id } : {})
|
|
72
|
-
};
|
|
73
|
-
}),
|
|
74
|
-
totalChars: contents.reduce((total, content) => total + content.length, 0),
|
|
75
|
-
truncated: contents.some((content, index) => allocations[index] < content.length)
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
function summarizeTaskTraceRound(value) {
|
|
79
|
-
const round = traceRecord(value);
|
|
80
|
-
const request = traceRecord(round.request);
|
|
81
|
-
const messages = Array.isArray(request.messages) ? request.messages : [];
|
|
82
|
-
const attempts = Array.isArray(round.attempts) ? round.attempts : [];
|
|
83
|
-
const toolExecutions = Array.isArray(round.toolExecutions) ? round.toolExecutions : [];
|
|
84
|
-
return {
|
|
85
|
-
round: typeof round.round === "number" ? round.round : 1,
|
|
86
|
-
requestedAt: typeof round.requestedAt === "string" ? round.requestedAt : "",
|
|
87
|
-
messageCount: messages.length,
|
|
88
|
-
promptChars: messages.reduce((total, message) => {
|
|
89
|
-
const content = traceRecord(message).content;
|
|
90
|
-
return total + (content === null ? 0 : String(content ?? "").length);
|
|
91
|
-
}, 0),
|
|
92
|
-
attemptCount: attempts.length,
|
|
93
|
-
toolExecutionCount: toolExecutions.length
|
|
94
|
-
};
|
|
79
|
+
return refs;
|
|
95
80
|
}
|
|
96
81
|
function redactProviderSecret(value, apiKey) {
|
|
97
82
|
if (!apiKey)
|
|
@@ -222,6 +207,11 @@ export function estimateAiTokens(value) {
|
|
|
222
207
|
}
|
|
223
208
|
return Math.max(1, Math.ceil(wideCharacters * 1.1 + narrowCharacters / 4));
|
|
224
209
|
}
|
|
210
|
+
export function collapseAiBlankLines(value) {
|
|
211
|
+
return value
|
|
212
|
+
.replace(/\r\n?/gu, "\n")
|
|
213
|
+
.replace(/\n[\t ]*\n(?:[\t ]*\n)+/gu, "\n\n");
|
|
214
|
+
}
|
|
225
215
|
function contextSearchTerms(value) {
|
|
226
216
|
const normalized = value.normalize("NFKC").toLocaleLowerCase("zh-CN");
|
|
227
217
|
const terms = new Set();
|
|
@@ -573,17 +563,20 @@ export class ContextBuilder {
|
|
|
573
563
|
}
|
|
574
564
|
buildPlan(workId, scope, maximumTokens = 60_000, bookSummaryMaximumTokens, query = "") {
|
|
575
565
|
const work = this.store.getWork(workId);
|
|
576
|
-
const includeAutomaticContext = scope.type !== "none";
|
|
566
|
+
const includeAutomaticContext = scope.type !== "none" && scope.suppressAutomaticContext !== true;
|
|
567
|
+
const settingsOnly = scope.type === "settings";
|
|
577
568
|
const constraints = includeAutomaticContext
|
|
578
569
|
? [`作品:${String(work.title)}\n作者:${String(work.author) || "未填写"}`]
|
|
579
570
|
: [];
|
|
580
571
|
const contentSections = [];
|
|
581
572
|
const availableSettings = this.store.listSettings(workId);
|
|
582
|
-
const contextualSettings =
|
|
573
|
+
const contextualSettings = !includeAutomaticContext || settingsOnly
|
|
574
|
+
? []
|
|
575
|
+
: scope.includeAllSettings ? availableSettings : availableSettings.filter((item) => item.locked);
|
|
583
576
|
const allCharacters = this.store.listCharacters(workId);
|
|
584
577
|
const lockedCharacters = allCharacters.filter((item) => Array.isArray(item.lockedFields) && item.lockedFields.length > 0);
|
|
585
578
|
const organizations = this.store.listOrganizations(workId);
|
|
586
|
-
const relationshipConstraints = scope.excludeRelationshipConstraints
|
|
579
|
+
const relationshipConstraints = !includeAutomaticContext || settingsOnly || scope.excludeRelationshipConstraints
|
|
587
580
|
? []
|
|
588
581
|
: selectRelationshipConstraints(this.store, workId, scope.characterIds ?? []);
|
|
589
582
|
if (includeAutomaticContext && contextualSettings.length > 0) {
|
|
@@ -591,7 +584,7 @@ export class ContextBuilder {
|
|
|
591
584
|
.map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`)
|
|
592
585
|
.join("\n")}`);
|
|
593
586
|
}
|
|
594
|
-
if (includeAutomaticContext && lockedCharacters.length > 0) {
|
|
587
|
+
if (includeAutomaticContext && !settingsOnly && lockedCharacters.length > 0) {
|
|
595
588
|
constraints.push(`作者锁定角色属性(硬约束):\n${lockedCharacters
|
|
596
589
|
.map((item) => {
|
|
597
590
|
const locked = item.lockedFields;
|
|
@@ -606,7 +599,7 @@ export class ContextBuilder {
|
|
|
606
599
|
})
|
|
607
600
|
.join("\n")}`);
|
|
608
601
|
}
|
|
609
|
-
if (includeAutomaticContext && organizations.length > 0) {
|
|
602
|
+
if (includeAutomaticContext && !settingsOnly && organizations.length > 0) {
|
|
610
603
|
constraints.push(`世界内组织:\n${organizations.map((item) => {
|
|
611
604
|
const settings = Array.isArray(item.settings) ? item.settings.map(String).filter(Boolean) : [];
|
|
612
605
|
const members = Array.isArray(item.members)
|
|
@@ -663,6 +656,9 @@ export class ContextBuilder {
|
|
|
663
656
|
}
|
|
664
657
|
}
|
|
665
658
|
}
|
|
659
|
+
else if (scope.type === "settings" && scope.selection) {
|
|
660
|
+
contentSections.push(`待分析设定:\n${scope.selection}`);
|
|
661
|
+
}
|
|
666
662
|
if (scope.includeBookSummary || scope.type === "book" || scope.type === "volume") {
|
|
667
663
|
this.appendBookSummary(contentSections, workId, bookSummaryMaximumTokens ?? Math.max(160, Math.floor(maximumTokens * 0.35)), query, scope.type === "volume" ? scope.volumeId : undefined);
|
|
668
664
|
}
|
|
@@ -691,7 +687,9 @@ export class ContextBuilder {
|
|
|
691
687
|
if (setting.workId !== workId)
|
|
692
688
|
throw new AppError(400, "SETTING_WORK_MISMATCH", "设定不属于当前作品");
|
|
693
689
|
}
|
|
694
|
-
constraints.push(
|
|
690
|
+
constraints.push(settingsOnly
|
|
691
|
+
? `设定集条目:\n${settings.map((item) => `<SETTING id="${String(item.id)}" title="${String(item.title).replaceAll('"', "'")}">\n${String(item.content)}\n</SETTING>`).join("\n\n")}`
|
|
692
|
+
: `选定设定:\n${settings.map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`).join("\n")}`);
|
|
695
693
|
}
|
|
696
694
|
if (scope.chapterIds?.length) {
|
|
697
695
|
const chapterIds = [...new Set(scope.chapterIds)]
|
|
@@ -718,7 +716,7 @@ export class ContextBuilder {
|
|
|
718
716
|
});
|
|
719
717
|
}
|
|
720
718
|
const sections = contentSections.map((text, order) => {
|
|
721
|
-
const required = /^(
|
|
719
|
+
const required = /^(?:当前选中文本|当前章节|所在章节|作者主动引用的章节|待分析设定)/u.test(text);
|
|
722
720
|
const summary = /章节概要(/u.test(text);
|
|
723
721
|
return {
|
|
724
722
|
id: `context-${order}`,
|
|
@@ -858,18 +856,30 @@ export class AiManager {
|
|
|
858
856
|
vault;
|
|
859
857
|
fetchImpl;
|
|
860
858
|
validateOutboundUrl;
|
|
859
|
+
authorizeTaskRun;
|
|
861
860
|
contextBuilder;
|
|
862
861
|
taskControllers = new Map();
|
|
863
862
|
autoRunBatches = new Map();
|
|
864
863
|
autoRunTimers = new Map();
|
|
864
|
+
relationshipIndexBuilds = new Map();
|
|
865
|
+
relationshipSelectionCache = new Map();
|
|
866
|
+
relationshipSelectionBuilds = new Map();
|
|
867
|
+
relationshipIndexSerial = Promise.resolve();
|
|
868
|
+
relationshipIndexTimer = null;
|
|
869
|
+
relationshipIndexDisposed = false;
|
|
865
870
|
providerSchedules = new Map();
|
|
866
|
-
constructor(store, vault, fetchImpl = fetch, validateOutboundUrl) {
|
|
871
|
+
constructor(store, vault, fetchImpl = fetch, validateOutboundUrl, authorizeTaskRun) {
|
|
867
872
|
this.store = store;
|
|
868
873
|
this.vault = vault;
|
|
869
874
|
this.fetchImpl = fetchImpl;
|
|
870
875
|
this.validateOutboundUrl = validateOutboundUrl;
|
|
876
|
+
this.authorizeTaskRun = authorizeTaskRun;
|
|
871
877
|
this.contextBuilder = new ContextBuilder(store);
|
|
872
878
|
this.store.setAnalysisTaskQueuedHandler((workId) => this.scheduleAutoRun(workId));
|
|
879
|
+
this.relationshipIndexTimer = setTimeout(() => {
|
|
880
|
+
this.relationshipIndexTimer = null;
|
|
881
|
+
void this.schedulePendingRelationshipIndexes();
|
|
882
|
+
}, 0);
|
|
873
883
|
logger.info("ai.manager.ready");
|
|
874
884
|
}
|
|
875
885
|
resetAutoRunBatch(workId) {
|
|
@@ -921,6 +931,10 @@ export class AiManager {
|
|
|
921
931
|
clearTimeout(timer);
|
|
922
932
|
this.autoRunTimers.clear();
|
|
923
933
|
this.autoRunBatches.clear();
|
|
934
|
+
this.relationshipIndexDisposed = true;
|
|
935
|
+
if (this.relationshipIndexTimer)
|
|
936
|
+
clearTimeout(this.relationshipIndexTimer);
|
|
937
|
+
this.relationshipIndexTimer = null;
|
|
924
938
|
this.store.setAnalysisTaskQueuedHandler(null);
|
|
925
939
|
logger.info("ai.manager.disposed");
|
|
926
940
|
}
|
|
@@ -1099,14 +1113,27 @@ export class AiManager {
|
|
|
1099
1113
|
}
|
|
1100
1114
|
listPlatformModels() {
|
|
1101
1115
|
return this.store.db
|
|
1102
|
-
.all(
|
|
1103
|
-
|
|
1116
|
+
.all(`SELECT m.*, p.name AS provider_name, p.status AS provider_status, p.connection_status AS provider_connection_status
|
|
1117
|
+
FROM models m JOIN providers p ON p.id = m.provider_id
|
|
1118
|
+
WHERE p.work_id = ? ORDER BY p.created_at, m.created_at`, PLATFORM_AI_WORK_ID)
|
|
1119
|
+
.map((row) => ({
|
|
1120
|
+
...this.mapModel(row),
|
|
1121
|
+
providerName: stringValue(row, "provider_name"),
|
|
1122
|
+
providerStatus: stringValue(row, "provider_status"),
|
|
1123
|
+
providerConnectionStatus: stringValue(row, "provider_connection_status")
|
|
1124
|
+
}));
|
|
1104
1125
|
}
|
|
1105
1126
|
listPlatformModelsPage(pagination) {
|
|
1106
1127
|
const page = paginationSql(pagination);
|
|
1107
|
-
const rows = this.store.db.all(`SELECT m.*, p.name AS provider_name
|
|
1128
|
+
const rows = this.store.db.all(`SELECT m.*, p.name AS provider_name, p.status AS provider_status, p.connection_status AS provider_connection_status
|
|
1129
|
+
FROM models m JOIN providers p ON p.id = m.provider_id
|
|
1108
1130
|
WHERE p.work_id = ? ORDER BY p.created_at, m.created_at${page.sql}`, PLATFORM_AI_WORK_ID, ...page.params);
|
|
1109
|
-
return paginated(rows.map((row) => ({
|
|
1131
|
+
return paginated(rows.map((row) => ({
|
|
1132
|
+
...this.mapModel(row),
|
|
1133
|
+
providerName: stringValue(row, "provider_name"),
|
|
1134
|
+
providerStatus: stringValue(row, "provider_status"),
|
|
1135
|
+
providerConnectionStatus: stringValue(row, "provider_connection_status")
|
|
1136
|
+
})), pagination);
|
|
1110
1137
|
}
|
|
1111
1138
|
listWorkModels(workId) {
|
|
1112
1139
|
this.store.getWork(workId);
|
|
@@ -1158,6 +1185,19 @@ export class AiManager {
|
|
|
1158
1185
|
model: this.getModel(stringValue(row, "model_id"))
|
|
1159
1186
|
})), pagination);
|
|
1160
1187
|
}
|
|
1188
|
+
createTask(workId, input) {
|
|
1189
|
+
this.store.getWork(workId);
|
|
1190
|
+
const modelPurpose = this.analysisTaskModelPurpose(input.taskType);
|
|
1191
|
+
const defaultRow = this.store.db.get("SELECT model_id FROM task_defaults WHERE work_id = ? AND task_type = ?", workId, modelPurpose);
|
|
1192
|
+
const modelId = input.modelId ?? (defaultRow ? stringValue(defaultRow, "model_id") : undefined);
|
|
1193
|
+
if (modelId)
|
|
1194
|
+
this.resolveModel(workId, modelPurpose, modelId);
|
|
1195
|
+
return this.store.createTask(workId, {
|
|
1196
|
+
taskType: input.taskType,
|
|
1197
|
+
...(input.scope ? { scope: input.scope } : {}),
|
|
1198
|
+
...(modelId ? { modelId } : {})
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1161
1201
|
async createSuggestion(input) {
|
|
1162
1202
|
const action = input.taskType === "continue" ? "append" : input.taskType === "polish" ? "replace-selection" : "note";
|
|
1163
1203
|
if (action === "replace-selection" && !input.scope.selection) {
|
|
@@ -1372,9 +1412,10 @@ export class AiManager {
|
|
|
1372
1412
|
})), pagination);
|
|
1373
1413
|
}
|
|
1374
1414
|
getTaskTrace(taskId) {
|
|
1375
|
-
this.store.
|
|
1415
|
+
this.store.getTaskWorkId(taskId);
|
|
1376
1416
|
const rows = this.store.db.all(`SELECT call.id, call.task_type, call.provider_id, call.model_id, call.status, call.failure,
|
|
1377
1417
|
call.input_chars, call.output_chars, call.created_at, call.completed_at, trace.call_id AS trace_call_id,
|
|
1418
|
+
trace.source_refs_json,
|
|
1378
1419
|
CASE WHEN trace.call_id IS NULL THEN 0 ELSE json_array_length(trace.initial_messages_json) END AS initial_message_count,
|
|
1379
1420
|
CASE WHEN trace.call_id IS NULL THEN 0 ELSE json_array_length(trace.rounds_json) END AS round_count,
|
|
1380
1421
|
CASE WHEN trace.call_id IS NULL THEN 0 ELSE length(trace.initial_messages_json) + length(trace.rounds_json) END AS trace_chars,
|
|
@@ -1389,6 +1430,9 @@ export class AiManager {
|
|
|
1389
1430
|
const calls = rows.map((row) => {
|
|
1390
1431
|
const hasTrace = row.trace_call_id !== null && row.trace_call_id !== undefined;
|
|
1391
1432
|
const failure = row.failure === null ? null : stringValue(row, "failure");
|
|
1433
|
+
const sourceRefs = hasTrace
|
|
1434
|
+
? json(stringValue(row, "source_refs_json"), [])
|
|
1435
|
+
: [];
|
|
1392
1436
|
return {
|
|
1393
1437
|
id: stringValue(row, "id"),
|
|
1394
1438
|
taskType: stringValue(row, "task_type"),
|
|
@@ -1410,6 +1454,7 @@ export class AiManager {
|
|
|
1410
1454
|
outputChars: numberValue(row, "output_chars"),
|
|
1411
1455
|
createdAt: stringValue(row, "created_at"),
|
|
1412
1456
|
completedAt: row.completed_at === null ? null : stringValue(row, "completed_at"),
|
|
1457
|
+
sourceRefs,
|
|
1413
1458
|
trace: hasTrace ? {
|
|
1414
1459
|
available: true,
|
|
1415
1460
|
initialMessageCount: numberValue(row, "initial_message_count"),
|
|
@@ -1426,8 +1471,8 @@ export class AiManager {
|
|
|
1426
1471
|
calls
|
|
1427
1472
|
};
|
|
1428
1473
|
}
|
|
1429
|
-
getTaskTraceCall(taskId, callId
|
|
1430
|
-
this.store.
|
|
1474
|
+
getTaskTraceCall(taskId, callId) {
|
|
1475
|
+
this.store.getTaskWorkId(taskId);
|
|
1431
1476
|
const row = this.store.db.get(`SELECT trace.initial_messages_json, trace.rounds_json, trace.created_at, trace.updated_at
|
|
1432
1477
|
FROM ai_calls call JOIN ai_call_traces trace ON trace.call_id = call.id AND trace.task_id = call.task_id
|
|
1433
1478
|
WHERE call.id = ? AND call.task_id = ?`, callId, taskId);
|
|
@@ -1435,41 +1480,29 @@ export class AiManager {
|
|
|
1435
1480
|
throw notFound("AI 调用追踪");
|
|
1436
1481
|
const initialMessages = json(stringValue(row, "initial_messages_json"), []);
|
|
1437
1482
|
const rounds = json(stringValue(row, "rounds_json"), []);
|
|
1438
|
-
if (full) {
|
|
1439
|
-
return {
|
|
1440
|
-
taskId,
|
|
1441
|
-
callId,
|
|
1442
|
-
mode: "full",
|
|
1443
|
-
trace: {
|
|
1444
|
-
initialMessages,
|
|
1445
|
-
rounds,
|
|
1446
|
-
createdAt: stringValue(row, "created_at"),
|
|
1447
|
-
updatedAt: stringValue(row, "updated_at")
|
|
1448
|
-
}
|
|
1449
|
-
};
|
|
1450
|
-
}
|
|
1451
|
-
const preview = previewTaskTraceMessages(initialMessages);
|
|
1452
1483
|
return {
|
|
1453
1484
|
taskId,
|
|
1454
1485
|
callId,
|
|
1455
|
-
mode: "
|
|
1456
|
-
previewLimit: TASK_TRACE_PREVIEW_CHARACTER_LIMIT,
|
|
1457
|
-
truncated: preview.truncated,
|
|
1458
|
-
totalPromptChars: preview.totalChars,
|
|
1486
|
+
mode: "full",
|
|
1459
1487
|
trace: {
|
|
1460
|
-
initialMessages
|
|
1461
|
-
rounds
|
|
1488
|
+
initialMessages,
|
|
1489
|
+
rounds,
|
|
1462
1490
|
createdAt: stringValue(row, "created_at"),
|
|
1463
1491
|
updatedAt: stringValue(row, "updated_at")
|
|
1464
1492
|
}
|
|
1465
1493
|
};
|
|
1466
1494
|
}
|
|
1467
|
-
async runTask(taskId, modelId) {
|
|
1495
|
+
async runTask(taskId, modelId, actor) {
|
|
1468
1496
|
const task = this.store.getTask(taskId);
|
|
1497
|
+
this.authorizeTaskRun?.(task, actor);
|
|
1469
1498
|
const workId = String(task.workId);
|
|
1499
|
+
const taskModel = task.model && typeof task.model === "object" && !Array.isArray(task.model)
|
|
1500
|
+
? task.model
|
|
1501
|
+
: null;
|
|
1502
|
+
const selectedModelId = modelId ?? (typeof taskModel?.id === "string" ? taskModel.id : undefined);
|
|
1470
1503
|
const batch = this.getAutoRunBatch(workId);
|
|
1471
1504
|
const startedAt = process.hrtime.bigint();
|
|
1472
|
-
logger.info("ai.task.started", { taskId, workId, taskType: task.taskType, modelId:
|
|
1505
|
+
logger.info("ai.task.started", { taskId, workId, taskType: task.taskType, modelId: selectedModelId ?? null });
|
|
1473
1506
|
if (task.status !== "pending")
|
|
1474
1507
|
throw new AppError(409, "TASK_NOT_PENDING", "只有待执行任务可以运行");
|
|
1475
1508
|
if (!this.store.isTaskSourceCurrent(taskId)) {
|
|
@@ -1492,28 +1525,28 @@ export class AiManager {
|
|
|
1492
1525
|
const scope = task.scope;
|
|
1493
1526
|
let result;
|
|
1494
1527
|
if (taskType === "chapter-analysis") {
|
|
1495
|
-
result = await this.runChapterAnalysis(workId, scope,
|
|
1528
|
+
result = await this.runChapterAnalysis(workId, scope, selectedModelId, taskId);
|
|
1496
1529
|
}
|
|
1497
1530
|
else if (taskType === "character-extraction" || taskType === "character-summary") {
|
|
1498
|
-
result = await this.runCharacterExtraction(workId, scope,
|
|
1531
|
+
result = await this.runCharacterExtraction(workId, scope, selectedModelId, taskId);
|
|
1499
1532
|
}
|
|
1500
1533
|
else if (taskType === "character-identity-audit") {
|
|
1501
|
-
result = await this.runCharacterIdentityAudit(workId, scope,
|
|
1534
|
+
result = await this.runCharacterIdentityAudit(workId, scope, selectedModelId, taskId);
|
|
1502
1535
|
}
|
|
1503
1536
|
else if (taskType === "timeline-analysis") {
|
|
1504
|
-
result = await this.runTimelineAnalysis(workId, scope,
|
|
1537
|
+
result = await this.runTimelineAnalysis(workId, scope, selectedModelId, taskId);
|
|
1505
1538
|
}
|
|
1506
1539
|
else if (taskType === "relationship-analysis") {
|
|
1507
|
-
result = await this.runRelationshipAnalysis(workId, scope,
|
|
1540
|
+
result = await this.runRelationshipAnalysis(workId, scope, selectedModelId, taskId);
|
|
1508
1541
|
}
|
|
1509
1542
|
else if (taskType === "worldview-analysis") {
|
|
1510
|
-
result = await this.runWorldviewAnalysis(workId, scope,
|
|
1543
|
+
result = await this.runWorldviewAnalysis(workId, scope, selectedModelId, taskId);
|
|
1511
1544
|
}
|
|
1512
1545
|
else if (taskType === "setting-extraction") {
|
|
1513
|
-
result = await this.runSettingExtraction(workId, scope,
|
|
1546
|
+
result = await this.runSettingExtraction(workId, scope, selectedModelId, taskId);
|
|
1514
1547
|
}
|
|
1515
1548
|
else if (taskType === "consistency-check") {
|
|
1516
|
-
result = await this.runConsistencyCheck(workId, scope,
|
|
1549
|
+
result = await this.runConsistencyCheck(workId, scope, selectedModelId, taskId);
|
|
1517
1550
|
}
|
|
1518
1551
|
else {
|
|
1519
1552
|
const generated = await this.generate({
|
|
@@ -1523,7 +1556,7 @@ export class AiManager {
|
|
|
1523
1556
|
instruction: "请基于上下文完成分析,给出有原文依据的中文结论。",
|
|
1524
1557
|
scope,
|
|
1525
1558
|
signal: taskController.signal,
|
|
1526
|
-
...(
|
|
1559
|
+
...(selectedModelId ? { modelId: selectedModelId } : {})
|
|
1527
1560
|
});
|
|
1528
1561
|
result = { content: generated.content, callId: generated.callId };
|
|
1529
1562
|
}
|
|
@@ -1754,7 +1787,7 @@ export class AiManager {
|
|
|
1754
1787
|
return this.contextBuilder.buildPlan(input.workId, input.scope, workContextBudgetTokens, bookSummaryMaximumTokens, input.instruction);
|
|
1755
1788
|
}
|
|
1756
1789
|
buildContext(input, model) {
|
|
1757
|
-
return this.buildContextPlan(input, model).context;
|
|
1790
|
+
return collapseAiBlankLines(this.buildContextPlan(input, model).context);
|
|
1758
1791
|
}
|
|
1759
1792
|
enabledAgentToolIds(workId, taskType, requestedToolIds) {
|
|
1760
1793
|
if (taskType !== "chat" && requestedToolIds === undefined)
|
|
@@ -1862,7 +1895,7 @@ export class AiManager {
|
|
|
1862
1895
|
const chapter = this.store.getChapter(chapterId);
|
|
1863
1896
|
if (chapter.workId !== workId)
|
|
1864
1897
|
return { chapterId, error: { code: "CHAPTER_WORK_MISMATCH", message: "The requested chapter belongs to a different work." } };
|
|
1865
|
-
const content = String(chapter.content);
|
|
1898
|
+
const content = collapseAiBlankLines(String(chapter.content));
|
|
1866
1899
|
const excerpt = content.slice(0, Math.max(0, remainingChars));
|
|
1867
1900
|
remainingChars -= excerpt.length;
|
|
1868
1901
|
return { chapterId, title: chapter.title, versionNo: chapter.versionNo, ...(include !== "content" ? { summary: summaries.get(chapterId) ?? "" } : {}), ...(include !== "summary" ? { content: excerpt, contentTruncated: excerpt.length < content.length } : {}) };
|
|
@@ -1924,7 +1957,7 @@ export class AiManager {
|
|
|
1924
1957
|
const section = this.store.getCharacterProfileSection(sectionId);
|
|
1925
1958
|
if (section.workId !== workId)
|
|
1926
1959
|
return { sectionId, error: { code: "CHARACTER_SECTION_WORK_MISMATCH", message: "The requested character section belongs to a different work." } };
|
|
1927
|
-
const content = String(section.contentMarkdown);
|
|
1960
|
+
const content = collapseAiBlankLines(String(section.contentMarkdown));
|
|
1928
1961
|
const excerpt = content.slice(0, Math.max(0, remainingChars));
|
|
1929
1962
|
remainingChars -= excerpt.length;
|
|
1930
1963
|
const character = this.store.getCharacter(String(section.characterId));
|
|
@@ -1985,14 +2018,14 @@ export class AiManager {
|
|
|
1985
2018
|
this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
|
|
1986
2019
|
status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskId ?? null, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(parameters), context.length + input.instruction.length, timestamp, currentRequestActor()?.userId ?? null);
|
|
1987
2020
|
if (input.taskId) {
|
|
1988
|
-
this.store.db.run(`INSERT INTO ai_call_traces (call_id, task_id, initial_messages_json, rounds_json, created_at, updated_at)
|
|
1989
|
-
VALUES (?, ?, ?, '[]', ?, ?)`, callId, input.taskId, JSON.stringify(messages), timestamp, timestamp);
|
|
2021
|
+
this.store.db.run(`INSERT INTO ai_call_traces (call_id, task_id, initial_messages_json, rounds_json, source_refs_json, created_at, updated_at)
|
|
2022
|
+
VALUES (?, ?, ?, '[]', ?, ?, ?)`, callId, input.taskId, JSON.stringify(messages), JSON.stringify(taskTraceSourceRefs(messages, [])), timestamp, timestamp);
|
|
1990
2023
|
}
|
|
1991
2024
|
});
|
|
1992
2025
|
const saveTrace = () => {
|
|
1993
2026
|
if (!input.taskId)
|
|
1994
2027
|
return;
|
|
1995
|
-
this.store.db.run("UPDATE ai_call_traces SET rounds_json = ?, updated_at = ? WHERE call_id = ?", JSON.stringify(traceRounds), now(), callId);
|
|
2028
|
+
this.store.db.run("UPDATE ai_call_traces SET rounds_json = ?, source_refs_json = ?, updated_at = ? WHERE call_id = ?", JSON.stringify(traceRounds), JSON.stringify(taskTraceSourceRefs(messages, traceRounds)), now(), callId);
|
|
1996
2029
|
};
|
|
1997
2030
|
const callStartedAt = process.hrtime.bigint();
|
|
1998
2031
|
logger.info("ai.call.started", {
|
|
@@ -3317,10 +3350,836 @@ export class AiManager {
|
|
|
3317
3350
|
}
|
|
3318
3351
|
};
|
|
3319
3352
|
}
|
|
3353
|
+
async schedulePendingRelationshipIndexes() {
|
|
3354
|
+
if (this.relationshipIndexDisposed)
|
|
3355
|
+
return;
|
|
3356
|
+
const workIds = this.store.db.all("SELECT DISTINCT work_id FROM relationship_source_index_queue ORDER BY work_id").map((row) => String(row.work_id));
|
|
3357
|
+
await Promise.allSettled(workIds.map((workId) => this.ensureRelationshipSearchIndex(workId)));
|
|
3358
|
+
}
|
|
3359
|
+
ensureRelationshipSearchIndex(workId) {
|
|
3360
|
+
const existing = this.relationshipIndexBuilds.get(workId);
|
|
3361
|
+
if (existing)
|
|
3362
|
+
return existing;
|
|
3363
|
+
const build = this.relationshipIndexSerial.then(async () => this.drainRelationshipSearchIndex(workId));
|
|
3364
|
+
this.relationshipIndexSerial = build.then(() => undefined, () => undefined);
|
|
3365
|
+
this.relationshipIndexBuilds.set(workId, build);
|
|
3366
|
+
void build.finally(() => {
|
|
3367
|
+
if (this.relationshipIndexBuilds.get(workId) === build)
|
|
3368
|
+
this.relationshipIndexBuilds.delete(workId);
|
|
3369
|
+
}).catch(() => undefined);
|
|
3370
|
+
return build;
|
|
3371
|
+
}
|
|
3372
|
+
async drainRelationshipSearchIndex(workId) {
|
|
3373
|
+
if (this.relationshipIndexDisposed)
|
|
3374
|
+
return 0;
|
|
3375
|
+
const timestamp = now();
|
|
3376
|
+
this.store.db.run(`INSERT INTO relationship_source_index_state(work_id, status, generation, error, updated_at)
|
|
3377
|
+
VALUES (?, 'building', 0, '', ?)
|
|
3378
|
+
ON CONFLICT(work_id) DO UPDATE SET status = 'building', error = '', updated_at = excluded.updated_at`, workId, timestamp);
|
|
3379
|
+
let processed = 0;
|
|
3380
|
+
try {
|
|
3381
|
+
while (!this.relationshipIndexDisposed) {
|
|
3382
|
+
const queued = this.store.db.all(`SELECT source_type, source_id, queued_at FROM relationship_source_index_queue
|
|
3383
|
+
WHERE work_id = ? ORDER BY queued_at, source_type, source_id LIMIT 50`, workId);
|
|
3384
|
+
if (queued.length === 0)
|
|
3385
|
+
break;
|
|
3386
|
+
for (const item of queued) {
|
|
3387
|
+
const sourceType = String(item.source_type);
|
|
3388
|
+
const sourceId = String(item.source_id);
|
|
3389
|
+
const queuedAt = String(item.queued_at);
|
|
3390
|
+
this.store.db.transaction(() => {
|
|
3391
|
+
if (sourceType === "chapter")
|
|
3392
|
+
this.indexRelationshipChapter(workId, sourceId);
|
|
3393
|
+
else
|
|
3394
|
+
this.indexRelationshipSettingSource(workId, sourceType, sourceId);
|
|
3395
|
+
this.store.db.run(`DELETE FROM relationship_source_index_queue
|
|
3396
|
+
WHERE work_id = ? AND source_type = ? AND source_id = ? AND queued_at = ?`, workId, sourceType, sourceId, queuedAt);
|
|
3397
|
+
});
|
|
3398
|
+
processed += 1;
|
|
3399
|
+
}
|
|
3400
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
3401
|
+
}
|
|
3402
|
+
if (this.relationshipIndexDisposed) {
|
|
3403
|
+
this.store.db.run("UPDATE relationship_source_index_state SET status = 'queued', updated_at = ? WHERE work_id = ?", now(), workId);
|
|
3404
|
+
return 0;
|
|
3405
|
+
}
|
|
3406
|
+
this.store.db.run(`UPDATE relationship_source_index_state
|
|
3407
|
+
SET status = 'ready', generation = generation + ?, error = '', updated_at = ? WHERE work_id = ?`, processed > 0 ? 1 : 0, now(), workId);
|
|
3408
|
+
const generation = Number(this.store.db.get("SELECT generation FROM relationship_source_index_state WHERE work_id = ?", workId)?.generation ?? 0);
|
|
3409
|
+
logger.info("relationship.search_index.ready", { workId, generation, processed });
|
|
3410
|
+
return generation;
|
|
3411
|
+
}
|
|
3412
|
+
catch (error) {
|
|
3413
|
+
const message = error instanceof Error ? error.message : "索引构建失败";
|
|
3414
|
+
this.store.db.run("UPDATE relationship_source_index_state SET status = 'failed', error = ?, updated_at = ? WHERE work_id = ?", message.slice(0, 2_000), now(), workId);
|
|
3415
|
+
logger.error("relationship.search_index.failed", { workId, processed, error: sanitizeError(error) });
|
|
3416
|
+
throw error;
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
indexRelationshipChapter(workId, chapterId) {
|
|
3420
|
+
const chapter = this.store.db.get("SELECT id FROM chapters WHERE id = ? AND work_id = ?", chapterId, workId);
|
|
3421
|
+
if (!chapter)
|
|
3422
|
+
return;
|
|
3423
|
+
const paragraphs = this.store.db.all("SELECT id, search_content FROM chapter_paragraph_search WHERE chapter_id = ? ORDER BY paragraph_order", chapterId);
|
|
3424
|
+
for (const paragraph of paragraphs) {
|
|
3425
|
+
const rowId = Number(paragraph.id);
|
|
3426
|
+
this.store.db.run("DELETE FROM chapter_paragraph_pinyin_fts WHERE rowid = ?", rowId);
|
|
3427
|
+
this.store.db.run("INSERT INTO chapter_paragraph_pinyin_fts(rowid, pinyin_tokens) VALUES (?, ?)", rowId, relationshipPinyinTokenText(String(paragraph.search_content)));
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
indexRelationshipSettingSource(workId, sourceType, sourceId) {
|
|
3431
|
+
const materialized = this.relationshipSettingSource(workId, sourceType, sourceId);
|
|
3432
|
+
const existing = this.store.db.get("SELECT id FROM relationship_source_search WHERE work_id = ? AND source_type = ? AND source_id = ?", workId, sourceType, sourceId);
|
|
3433
|
+
if (!materialized) {
|
|
3434
|
+
if (existing)
|
|
3435
|
+
this.store.db.run("DELETE FROM relationship_source_search WHERE id = ?", Number(existing.id));
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3438
|
+
const searchable = `${materialized.title}\n${materialized.content}`;
|
|
3439
|
+
const contentHash = this.store.hashContent(searchable);
|
|
3440
|
+
let rowId = Number(existing?.id ?? 0);
|
|
3441
|
+
if (existing) {
|
|
3442
|
+
this.store.db.run(`UPDATE relationship_source_search SET source_version = ?, content_hash = ?, updated_at = ? WHERE id = ?`, materialized.version, contentHash, now(), rowId);
|
|
3443
|
+
this.store.db.run("DELETE FROM relationship_source_exact_fts WHERE rowid = ?", rowId);
|
|
3444
|
+
this.store.db.run("DELETE FROM relationship_source_pinyin_fts WHERE rowid = ?", rowId);
|
|
3445
|
+
}
|
|
3446
|
+
else {
|
|
3447
|
+
rowId = Number(this.store.db.run(`INSERT INTO relationship_source_search(work_id, source_type, source_id, source_version, content_hash, updated_at)
|
|
3448
|
+
VALUES (?, ?, ?, ?, ?, ?)`, workId, sourceType, sourceId, materialized.version, contentHash, now()).lastInsertRowid);
|
|
3449
|
+
}
|
|
3450
|
+
this.store.db.run("INSERT INTO relationship_source_exact_fts(rowid, character_tokens) VALUES (?, ?)", rowId, relationshipCharacterTokenText(searchable));
|
|
3451
|
+
this.store.db.run("INSERT INTO relationship_source_pinyin_fts(rowid, pinyin_tokens) VALUES (?, ?)", rowId, relationshipPinyinTokenText(searchable));
|
|
3452
|
+
}
|
|
3453
|
+
relationshipScopeChapterIds(workId, scope) {
|
|
3454
|
+
if (scope.type === "settings")
|
|
3455
|
+
return new Set();
|
|
3456
|
+
if (scope.type === "chapter") {
|
|
3457
|
+
if (!scope.chapterId)
|
|
3458
|
+
throw new AppError(400, "CHAPTER_REQUIRED", "分析范围缺少章节标识");
|
|
3459
|
+
const chapter = this.store.getChapter(scope.chapterId);
|
|
3460
|
+
if (String(chapter.workId) !== workId)
|
|
3461
|
+
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
3462
|
+
return new Set([scope.chapterId]);
|
|
3463
|
+
}
|
|
3464
|
+
if (scope.type === "volume") {
|
|
3465
|
+
if (!scope.volumeId)
|
|
3466
|
+
throw new AppError(400, "VOLUME_REQUIRED", "分析范围缺少分卷标识");
|
|
3467
|
+
const volume = this.store.getVolume(scope.volumeId);
|
|
3468
|
+
if (String(volume.workId) !== workId)
|
|
3469
|
+
throw new AppError(400, "VOLUME_WORK_MISMATCH", "分卷不属于当前作品");
|
|
3470
|
+
return new Set(this.store.db.all(`SELECT id FROM chapters WHERE work_id = ? AND volume_id = ?
|
|
3471
|
+
AND excluded_from_analysis = 0 AND chapter_type <> '作者的话'`, workId, scope.volumeId).map((row) => String(row.id)));
|
|
3472
|
+
}
|
|
3473
|
+
return new Set(this.store.db.all(`SELECT id FROM chapters WHERE work_id = ? AND excluded_from_analysis = 0 AND chapter_type <> '作者的话'`, workId).map((row) => String(row.id)));
|
|
3474
|
+
}
|
|
3475
|
+
relationshipIndexedSource(workId, sourceType, sourceId) {
|
|
3476
|
+
if (sourceType === "chapter") {
|
|
3477
|
+
try {
|
|
3478
|
+
const chapter = this.store.getChapter(sourceId);
|
|
3479
|
+
if (String(chapter.workId) !== workId)
|
|
3480
|
+
return null;
|
|
3481
|
+
return {
|
|
3482
|
+
sourceType,
|
|
3483
|
+
sourceId,
|
|
3484
|
+
title: String(chapter.title),
|
|
3485
|
+
content: String(chapter.content),
|
|
3486
|
+
version: String(chapter.versionNo)
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
3489
|
+
catch {
|
|
3490
|
+
return null;
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
const source = this.relationshipSettingSource(workId, sourceType, sourceId);
|
|
3494
|
+
return source ? {
|
|
3495
|
+
sourceType,
|
|
3496
|
+
sourceId,
|
|
3497
|
+
title: source.title,
|
|
3498
|
+
content: source.content,
|
|
3499
|
+
version: source.version
|
|
3500
|
+
} : null;
|
|
3501
|
+
}
|
|
3502
|
+
relationshipIndexedSourceKey(sourceType, sourceId) {
|
|
3503
|
+
return `${sourceType}:${sourceId}`;
|
|
3504
|
+
}
|
|
3505
|
+
relationshipIndexedSourceRef(key) {
|
|
3506
|
+
const separator = key.indexOf(":");
|
|
3507
|
+
return separator < 0
|
|
3508
|
+
? { sourceType: "setting", sourceId: key }
|
|
3509
|
+
: { sourceType: key.slice(0, separator), sourceId: key.slice(separator + 1) };
|
|
3510
|
+
}
|
|
3511
|
+
relationshipChapterExactMatches(workId, reference) {
|
|
3512
|
+
const normalized = normalizeRelationshipSearchText(reference).trim();
|
|
3513
|
+
if (!normalized)
|
|
3514
|
+
return [];
|
|
3515
|
+
const rows = [...normalized].length < 3
|
|
3516
|
+
? this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_short_terms term
|
|
3517
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = term.paragraph_id
|
|
3518
|
+
WHERE paragraph.work_id = ? AND term.term = ?`, workId, normalized)
|
|
3519
|
+
: this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_search_fts
|
|
3520
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_search_fts.rowid
|
|
3521
|
+
WHERE paragraph.work_id = ? AND chapter_paragraph_search_fts MATCH ?`, workId, `"${normalized.replaceAll('"', '""')}"`);
|
|
3522
|
+
return rows.map((row) => String(row.chapter_id));
|
|
3523
|
+
}
|
|
3524
|
+
relationshipSettingExactMatches(workId, reference) {
|
|
3525
|
+
const phrase = ftsPhrase(relationshipCharacterTokens(reference));
|
|
3526
|
+
return this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_exact_fts
|
|
3527
|
+
JOIN relationship_source_search source ON source.id = relationship_source_exact_fts.rowid
|
|
3528
|
+
WHERE source.work_id = ? AND relationship_source_exact_fts MATCH ?
|
|
3529
|
+
AND NOT (source.source_type = 'review' AND EXISTS (
|
|
3530
|
+
SELECT 1 FROM review_items review
|
|
3531
|
+
WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
|
|
3532
|
+
))`, workId, phrase).map((row) => this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
|
|
3533
|
+
}
|
|
3534
|
+
relationshipFuzzyIndexMatches(workId, reference, includeSettings, scope) {
|
|
3535
|
+
const result = new Set();
|
|
3536
|
+
const characterTokens = [...new Set(relationshipCharacterTokens(reference))];
|
|
3537
|
+
const pinyinTokens = [...new Set(relationshipPinyinTokens(reference))];
|
|
3538
|
+
const score = new Map();
|
|
3539
|
+
const add = (key) => {
|
|
3540
|
+
score.set(key, (score.get(key) ?? 0) + 1);
|
|
3541
|
+
};
|
|
3542
|
+
const chapterScope = scope.type === "chapter"
|
|
3543
|
+
? { sql: "AND paragraph.chapter_id = ?", params: [scope.chapterId ?? ""] }
|
|
3544
|
+
: scope.type === "volume"
|
|
3545
|
+
? {
|
|
3546
|
+
sql: `AND EXISTS (
|
|
3547
|
+
SELECT 1 FROM chapters chapter WHERE chapter.id = paragraph.chapter_id
|
|
3548
|
+
AND chapter.volume_id = ? AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'
|
|
3549
|
+
)`,
|
|
3550
|
+
params: [scope.volumeId ?? ""]
|
|
3551
|
+
}
|
|
3552
|
+
: {
|
|
3553
|
+
sql: `AND EXISTS (
|
|
3554
|
+
SELECT 1 FROM chapters chapter WHERE chapter.id = paragraph.chapter_id
|
|
3555
|
+
AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'
|
|
3556
|
+
)`,
|
|
3557
|
+
params: []
|
|
3558
|
+
};
|
|
3559
|
+
const includeChapters = scope.type !== "settings";
|
|
3560
|
+
const pinyinPhrase = ftsPhrase(relationshipPinyinTokens(reference));
|
|
3561
|
+
if (includeChapters) {
|
|
3562
|
+
for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_pinyin_fts
|
|
3563
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_pinyin_fts.rowid
|
|
3564
|
+
WHERE paragraph.work_id = ? AND chapter_paragraph_pinyin_fts MATCH ? ${chapterScope.sql}
|
|
3565
|
+
LIMIT 201`, workId, pinyinPhrase, ...chapterScope.params))
|
|
3566
|
+
result.add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
|
|
3567
|
+
}
|
|
3568
|
+
if (includeSettings) {
|
|
3569
|
+
for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_pinyin_fts
|
|
3570
|
+
JOIN relationship_source_search source ON source.id = relationship_source_pinyin_fts.rowid
|
|
3571
|
+
WHERE source.work_id = ? AND relationship_source_pinyin_fts MATCH ?
|
|
3572
|
+
AND NOT (source.source_type = 'review' AND EXISTS (
|
|
3573
|
+
SELECT 1 FROM review_items review
|
|
3574
|
+
WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
|
|
3575
|
+
))
|
|
3576
|
+
LIMIT 201`, workId, pinyinPhrase))
|
|
3577
|
+
result.add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
|
|
3578
|
+
}
|
|
3579
|
+
const normalizedCharacters = [...normalizeRelationshipSearchText(reference).trim()];
|
|
3580
|
+
if (includeChapters) {
|
|
3581
|
+
for (const character of [...new Set(normalizedCharacters)]) {
|
|
3582
|
+
for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_short_terms term
|
|
3583
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = term.paragraph_id
|
|
3584
|
+
WHERE paragraph.work_id = ? AND term.term = ? ${chapterScope.sql}
|
|
3585
|
+
LIMIT 201`, workId, character, ...chapterScope.params))
|
|
3586
|
+
add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
|
|
3587
|
+
}
|
|
3588
|
+
for (const token of pinyinTokens) {
|
|
3589
|
+
for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_pinyin_fts
|
|
3590
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_pinyin_fts.rowid
|
|
3591
|
+
WHERE paragraph.work_id = ? AND chapter_paragraph_pinyin_fts MATCH ? ${chapterScope.sql}
|
|
3592
|
+
LIMIT 201`, workId, token, ...chapterScope.params))
|
|
3593
|
+
add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
if (includeSettings) {
|
|
3597
|
+
for (const token of characterTokens) {
|
|
3598
|
+
for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_exact_fts
|
|
3599
|
+
JOIN relationship_source_search source ON source.id = relationship_source_exact_fts.rowid
|
|
3600
|
+
WHERE source.work_id = ? AND relationship_source_exact_fts MATCH ?
|
|
3601
|
+
AND NOT (source.source_type = 'review' AND EXISTS (
|
|
3602
|
+
SELECT 1 FROM review_items review
|
|
3603
|
+
WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
|
|
3604
|
+
))
|
|
3605
|
+
LIMIT 201`, workId, token))
|
|
3606
|
+
add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
|
|
3607
|
+
}
|
|
3608
|
+
for (const token of pinyinTokens) {
|
|
3609
|
+
for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_pinyin_fts
|
|
3610
|
+
JOIN relationship_source_search source ON source.id = relationship_source_pinyin_fts.rowid
|
|
3611
|
+
WHERE source.work_id = ? AND relationship_source_pinyin_fts MATCH ?
|
|
3612
|
+
AND NOT (source.source_type = 'review' AND EXISTS (
|
|
3613
|
+
SELECT 1 FROM review_items review
|
|
3614
|
+
WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
|
|
3615
|
+
))
|
|
3616
|
+
LIMIT 201`, workId, token))
|
|
3617
|
+
add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
const threshold = Math.max(1, [...normalizeRelationshipSearchText(reference).trim()].length - 1);
|
|
3621
|
+
for (const [key, count] of score)
|
|
3622
|
+
if (count >= threshold)
|
|
3623
|
+
result.add(key);
|
|
3624
|
+
return result;
|
|
3625
|
+
}
|
|
3626
|
+
relationshipIdentityAnchors(workId, character) {
|
|
3627
|
+
const characterId = String(character.id);
|
|
3628
|
+
const relatedIds = new Set();
|
|
3629
|
+
for (const relationship of this.store.listRelationships(workId)) {
|
|
3630
|
+
if (String(relationship.fromCharacterId) === characterId)
|
|
3631
|
+
relatedIds.add(String(relationship.toCharacterId));
|
|
3632
|
+
if (String(relationship.toCharacterId) === characterId)
|
|
3633
|
+
relatedIds.add(String(relationship.fromCharacterId));
|
|
3634
|
+
}
|
|
3635
|
+
const anchors = [
|
|
3636
|
+
String(character.code ?? ""),
|
|
3637
|
+
String(character.species ?? ""),
|
|
3638
|
+
String(character.race?.name ?? ""),
|
|
3639
|
+
...(Array.isArray(character.organizations) ? character.organizations.map((item) => String(item.name ?? "")) : []),
|
|
3640
|
+
...[...relatedIds].flatMap((relatedId) => {
|
|
3641
|
+
try {
|
|
3642
|
+
const related = this.store.getCharacter(relatedId);
|
|
3643
|
+
return [String(related.name), ...related.aliases];
|
|
3644
|
+
}
|
|
3645
|
+
catch {
|
|
3646
|
+
return [];
|
|
3647
|
+
}
|
|
3648
|
+
})
|
|
3649
|
+
].map((value) => normalizeRelationshipSearchText(value).trim())
|
|
3650
|
+
.filter((value) => [...value].length >= 2);
|
|
3651
|
+
return [...new Set(anchors)];
|
|
3652
|
+
}
|
|
3653
|
+
async localRelationshipSourceSelection(workId, scope, characters, selectedCharacterIds, generation) {
|
|
3654
|
+
const targetCharacters = characters.filter((character) => selectedCharacterIds.has(String(character.id)));
|
|
3655
|
+
const cacheKey = JSON.stringify({
|
|
3656
|
+
workId,
|
|
3657
|
+
scope: {
|
|
3658
|
+
type: scope.type,
|
|
3659
|
+
chapterId: scope.chapterId ?? null,
|
|
3660
|
+
volumeId: scope.volumeId ?? null,
|
|
3661
|
+
includeAllSettings: scope.includeAllSettings === true
|
|
3662
|
+
},
|
|
3663
|
+
targets: targetCharacters.map((character) => ({ id: character.id, versionNo: character.versionNo })),
|
|
3664
|
+
generation,
|
|
3665
|
+
policyVersion: RELATIONSHIP_SEARCH_POLICY_VERSION
|
|
3666
|
+
});
|
|
3667
|
+
const cached = this.relationshipSelectionCache.get(cacheKey);
|
|
3668
|
+
if (cached)
|
|
3669
|
+
return cached;
|
|
3670
|
+
const existingBuild = this.relationshipSelectionBuilds.get(cacheKey);
|
|
3671
|
+
if (existingBuild)
|
|
3672
|
+
return existingBuild;
|
|
3673
|
+
const build = (async () => {
|
|
3674
|
+
const allowedChapterIds = this.relationshipScopeChapterIds(workId, scope);
|
|
3675
|
+
const includeSettings = scope.type === "settings" || scope.includeAllSettings === true;
|
|
3676
|
+
const exactKeys = new Set();
|
|
3677
|
+
const candidates = [];
|
|
3678
|
+
const candidateKeys = new Set();
|
|
3679
|
+
const candidateOccurrences = new Map();
|
|
3680
|
+
const loadedSources = new Map();
|
|
3681
|
+
const knownCharacterReferences = new Set(characters.flatMap((character) => [
|
|
3682
|
+
String(character.name),
|
|
3683
|
+
...(Array.isArray(character.aliases) ? character.aliases.map(String) : [])
|
|
3684
|
+
]).map((value) => normalizeRelationshipSearchText(value).trim()).filter(Boolean));
|
|
3685
|
+
for (const character of targetCharacters) {
|
|
3686
|
+
const targetCharacterId = String(character.id);
|
|
3687
|
+
const exactReferences = [...new Set([String(character.name), ...character.aliases].map((item) => item.trim()).filter(Boolean))];
|
|
3688
|
+
const fuzzyReferenceCount = exactReferences.filter((reference) => [...normalizeRelationshipSearchText(reference).trim()].length >= 2).length;
|
|
3689
|
+
if (fuzzyReferenceCount > RELATIONSHIP_MAX_FUZZY_REFERENCES) {
|
|
3690
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "人物名称和别名过多,无法在安全预算内完成疑似写法匹配", {
|
|
3691
|
+
characterId: targetCharacterId,
|
|
3692
|
+
fuzzyReferenceCount,
|
|
3693
|
+
maximumFuzzyReferences: RELATIONSHIP_MAX_FUZZY_REFERENCES
|
|
3694
|
+
});
|
|
3695
|
+
}
|
|
3696
|
+
const normalizedExactReferences = new Set(exactReferences.map((item) => normalizeRelationshipSearchText(item).trim()));
|
|
3697
|
+
const anchors = this.relationshipIdentityAnchors(workId, character);
|
|
3698
|
+
const anchorKeys = new Set();
|
|
3699
|
+
for (const anchor of anchors) {
|
|
3700
|
+
for (const chapterId of this.relationshipChapterExactMatches(workId, anchor)) {
|
|
3701
|
+
if (allowedChapterIds.has(chapterId))
|
|
3702
|
+
anchorKeys.add(this.relationshipIndexedSourceKey("chapter", chapterId));
|
|
3703
|
+
}
|
|
3704
|
+
if (includeSettings)
|
|
3705
|
+
for (const key of this.relationshipSettingExactMatches(workId, anchor))
|
|
3706
|
+
anchorKeys.add(key);
|
|
3707
|
+
}
|
|
3708
|
+
const targetIndexCandidateKeys = new Set();
|
|
3709
|
+
const targetFuzzySourceKeys = new Set();
|
|
3710
|
+
let fuzzyScanCharacters = 0;
|
|
3711
|
+
let fuzzyMatchCount = 0;
|
|
3712
|
+
for (const reference of exactReferences) {
|
|
3713
|
+
for (const chapterId of this.relationshipChapterExactMatches(workId, reference)) {
|
|
3714
|
+
if (allowedChapterIds.has(chapterId))
|
|
3715
|
+
exactKeys.add(this.relationshipIndexedSourceKey("chapter", chapterId));
|
|
3716
|
+
}
|
|
3717
|
+
if (includeSettings)
|
|
3718
|
+
for (const key of this.relationshipSettingExactMatches(workId, reference))
|
|
3719
|
+
exactKeys.add(key);
|
|
3720
|
+
const referenceLength = [...normalizeRelationshipSearchText(reference).trim()].length;
|
|
3721
|
+
if (referenceLength < 2)
|
|
3722
|
+
continue;
|
|
3723
|
+
const fuzzyIndexKeys = referenceLength === 2
|
|
3724
|
+
? anchorKeys
|
|
3725
|
+
: this.relationshipFuzzyIndexMatches(workId, reference, includeSettings, scope);
|
|
3726
|
+
for (const key of fuzzyIndexKeys) {
|
|
3727
|
+
const ref = this.relationshipIndexedSourceRef(key);
|
|
3728
|
+
if (ref.sourceType === "chapter" && !allowedChapterIds.has(ref.sourceId))
|
|
3729
|
+
continue;
|
|
3730
|
+
if (ref.sourceType !== "chapter" && !includeSettings)
|
|
3731
|
+
continue;
|
|
3732
|
+
targetIndexCandidateKeys.add(key);
|
|
3733
|
+
if (targetIndexCandidateKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES) {
|
|
3734
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名来源过多,请补充人物别名或身份资料后重试", {
|
|
3735
|
+
characterId: targetCharacterId,
|
|
3736
|
+
candidateCount: targetIndexCandidateKeys.size,
|
|
3737
|
+
maximum: RELATIONSHIP_MAX_FUZZY_SOURCES
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
let indexed = loadedSources.get(key);
|
|
3741
|
+
if (!indexed) {
|
|
3742
|
+
const loaded = this.relationshipIndexedSource(workId, ref.sourceType, ref.sourceId);
|
|
3743
|
+
if (!loaded)
|
|
3744
|
+
continue;
|
|
3745
|
+
indexed = loaded;
|
|
3746
|
+
loadedSources.set(key, indexed);
|
|
3747
|
+
}
|
|
3748
|
+
if (indexed.sourceType === "review" && indexed.content.includes('"itemType": "character-name-variant"'))
|
|
3749
|
+
continue;
|
|
3750
|
+
const searchable = `${indexed.title}\n${indexed.content}`;
|
|
3751
|
+
const normalizedSearchable = normalizeRelationshipSearchText(searchable);
|
|
3752
|
+
fuzzyScanCharacters += normalizedSearchable.length;
|
|
3753
|
+
if (fuzzyScanCharacters > RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS) {
|
|
3754
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名待核对文本过多,请缩小分析范围或补充人物别名", {
|
|
3755
|
+
characterId: targetCharacterId,
|
|
3756
|
+
scannedCharacters: fuzzyScanCharacters,
|
|
3757
|
+
maximumScannedCharacters: RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS
|
|
3758
|
+
});
|
|
3759
|
+
}
|
|
3760
|
+
const referenceCharacters = [...normalizeRelationshipSearchText(reference).trim()];
|
|
3761
|
+
let approximateMatches;
|
|
3762
|
+
try {
|
|
3763
|
+
approximateMatches = await findApproximateNameMatchesChunked(searchable, reference, 24, knownCharacterReferences, RELATIONSHIP_MAX_SOURCE_MATCHES);
|
|
3764
|
+
}
|
|
3765
|
+
catch (error) {
|
|
3766
|
+
if (!(error instanceof RelationshipApproximateMatchLimitError))
|
|
3767
|
+
throw error;
|
|
3768
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "单个来源中的疑似人物名写法过多,请缩小分析范围或补充人物别名", {
|
|
3769
|
+
characterId: targetCharacterId,
|
|
3770
|
+
sourceType: indexed.sourceType,
|
|
3771
|
+
sourceId: indexed.sourceId,
|
|
3772
|
+
maximumSourceMatches: error.maximumCandidates
|
|
3773
|
+
});
|
|
3774
|
+
}
|
|
3775
|
+
for (const match of approximateMatches) {
|
|
3776
|
+
if (normalizedExactReferences.has(normalizeRelationshipSearchText(match.observed).trim()))
|
|
3777
|
+
continue;
|
|
3778
|
+
if (referenceLength === 2
|
|
3779
|
+
&& !anchors.some((anchor) => normalizedSearchable.includes(anchor)))
|
|
3780
|
+
continue;
|
|
3781
|
+
const occurrenceKey = [targetCharacterId, indexed.sourceType, indexed.sourceId, match.observed].join("|");
|
|
3782
|
+
const occurrenceCount = candidateOccurrences.get(occurrenceKey) ?? 0;
|
|
3783
|
+
if (occurrenceCount >= 3)
|
|
3784
|
+
continue;
|
|
3785
|
+
const candidateKey = [targetCharacterId, indexed.sourceType, indexed.sourceId, match.observed, reference, match.start].join("|");
|
|
3786
|
+
if (candidateKeys.has(candidateKey))
|
|
3787
|
+
continue;
|
|
3788
|
+
candidateKeys.add(candidateKey);
|
|
3789
|
+
candidateOccurrences.set(occurrenceKey, occurrenceCount + 1);
|
|
3790
|
+
fuzzyMatchCount += 1;
|
|
3791
|
+
if (fuzzyMatchCount > RELATIONSHIP_MAX_FUZZY_MATCHES) {
|
|
3792
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名写法过多,请缩小分析范围或补充人物别名", {
|
|
3793
|
+
characterId: targetCharacterId,
|
|
3794
|
+
fuzzyMatchCount,
|
|
3795
|
+
maximumFuzzyMatches: RELATIONSHIP_MAX_FUZZY_MATCHES
|
|
3796
|
+
});
|
|
3797
|
+
}
|
|
3798
|
+
targetFuzzySourceKeys.add(key);
|
|
3799
|
+
const snippetStart = Math.max(0, match.utf16Start - 240);
|
|
3800
|
+
const snippetEnd = Math.min(normalizedSearchable.length, match.utf16End + 240);
|
|
3801
|
+
candidates.push({
|
|
3802
|
+
key: candidateKey,
|
|
3803
|
+
targetCharacterId,
|
|
3804
|
+
targetName: String(character.name),
|
|
3805
|
+
reference,
|
|
3806
|
+
sourceType: indexed.sourceType,
|
|
3807
|
+
sourceId: indexed.sourceId,
|
|
3808
|
+
sourceTitle: indexed.title,
|
|
3809
|
+
sourceVersion: indexed.version,
|
|
3810
|
+
observed: match.observed,
|
|
3811
|
+
snippet: normalizedSearchable.slice(snippetStart, snippetEnd),
|
|
3812
|
+
characterDistance: match.characterDistance,
|
|
3813
|
+
pinyinDistance: match.pinyinDistance
|
|
3814
|
+
});
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
if (targetFuzzySourceKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES) {
|
|
3819
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名来源过多,请补充人物别名或身份资料后重试", {
|
|
3820
|
+
characterId: targetCharacterId,
|
|
3821
|
+
candidateCount: targetFuzzySourceKeys.size,
|
|
3822
|
+
maximum: RELATIONSHIP_MAX_FUZZY_SOURCES
|
|
3823
|
+
});
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
const result = { generation, exactKeys: [...exactKeys], candidates };
|
|
3827
|
+
this.relationshipSelectionCache.set(cacheKey, result);
|
|
3828
|
+
if (this.relationshipSelectionCache.size > 128) {
|
|
3829
|
+
const oldest = this.relationshipSelectionCache.keys().next().value;
|
|
3830
|
+
if (typeof oldest === "string")
|
|
3831
|
+
this.relationshipSelectionCache.delete(oldest);
|
|
3832
|
+
}
|
|
3833
|
+
return result;
|
|
3834
|
+
})();
|
|
3835
|
+
this.relationshipSelectionBuilds.set(cacheKey, build);
|
|
3836
|
+
try {
|
|
3837
|
+
return await build;
|
|
3838
|
+
}
|
|
3839
|
+
finally {
|
|
3840
|
+
if (this.relationshipSelectionBuilds.get(cacheKey) === build)
|
|
3841
|
+
this.relationshipSelectionBuilds.delete(cacheKey);
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
async verifyRelationshipVariantCandidates(workId, candidates, modelId, taskId) {
|
|
3845
|
+
if (candidates.length === 0)
|
|
3846
|
+
return { decisions: [], callIds: [] };
|
|
3847
|
+
const batches = [];
|
|
3848
|
+
let batch = [];
|
|
3849
|
+
let batchLength = 0;
|
|
3850
|
+
for (const candidate of candidates) {
|
|
3851
|
+
const length = JSON.stringify(candidate).length;
|
|
3852
|
+
if (batch.length > 0 && batchLength + length > 12_000) {
|
|
3853
|
+
batches.push(batch);
|
|
3854
|
+
batch = [];
|
|
3855
|
+
batchLength = 0;
|
|
3856
|
+
}
|
|
3857
|
+
batch.push(candidate);
|
|
3858
|
+
batchLength += length;
|
|
3859
|
+
}
|
|
3860
|
+
if (batch.length > 0)
|
|
3861
|
+
batches.push(batch);
|
|
3862
|
+
const decisions = [];
|
|
3863
|
+
const callIds = [];
|
|
3864
|
+
try {
|
|
3865
|
+
for (const candidateBatch of batches) {
|
|
3866
|
+
const snippets = candidateBatch.map((candidate) => {
|
|
3867
|
+
const tag = candidate.sourceType === "chapter" ? "CHAPTER" : "SETTING";
|
|
3868
|
+
return [
|
|
3869
|
+
`<${tag} id="${candidate.sourceId.replaceAll('"', "'")}" title="${candidate.sourceTitle.replaceAll('"', "'")}">`,
|
|
3870
|
+
JSON.stringify({
|
|
3871
|
+
key: candidate.key,
|
|
3872
|
+
targetCharacterId: candidate.targetCharacterId,
|
|
3873
|
+
targetName: candidate.targetName,
|
|
3874
|
+
registeredReference: candidate.reference,
|
|
3875
|
+
observed: candidate.observed,
|
|
3876
|
+
characterDistance: candidate.characterDistance,
|
|
3877
|
+
pinyinDistance: candidate.pinyinDistance,
|
|
3878
|
+
snippet: candidate.snippet
|
|
3879
|
+
}),
|
|
3880
|
+
`</${tag}>`
|
|
3881
|
+
].join("\n");
|
|
3882
|
+
}).join("\n");
|
|
3883
|
+
const generated = await this.generateTaggedJson({
|
|
3884
|
+
workId,
|
|
3885
|
+
taskId,
|
|
3886
|
+
taskType: "relationship-analysis",
|
|
3887
|
+
signal: this.taskSignal(taskId),
|
|
3888
|
+
maxAttempts: 2,
|
|
3889
|
+
scope: { type: "selection", selection: snippets, suppressAutomaticContext: true },
|
|
3890
|
+
...(modelId ? { modelId } : {}),
|
|
3891
|
+
parameters: { temperature: 0.1 },
|
|
3892
|
+
instruction: [
|
|
3893
|
+
"你是人物名称变体确认器。判断每个片段中的 observed 是否指向对应 targetName,而不是另一个人物、普通词语或无法判断的对象。",
|
|
3894
|
+
"只依据每个候选附带的局部片段判断,禁止使用未提供的正文或设定。",
|
|
3895
|
+
"必须为每个 key 恰好输出一次结果,不得遗漏、重复或新增 key。",
|
|
3896
|
+
"verdict 只能是 same、separate、uncertain;confidence 是 0 到 1;reason 使用简短中文说明片段内依据。",
|
|
3897
|
+
"拼音相同或字形相近只能说明疑似,不能单独作为 same 的依据。上下文不能可靠确认时必须输出 uncertain。",
|
|
3898
|
+
"输出 JSON 数组,字段为 key、verdict、confidence、reason。"
|
|
3899
|
+
].join("\n")
|
|
3900
|
+
});
|
|
3901
|
+
callIds.push(generated.callId);
|
|
3902
|
+
const extracted = extractJson(generated.content);
|
|
3903
|
+
if (!Array.isArray(extracted))
|
|
3904
|
+
throw new Error("variant verification result is not an array");
|
|
3905
|
+
const byKey = new Map(candidateBatch.map((candidate) => [candidate.key, candidate]));
|
|
3906
|
+
const seen = new Set();
|
|
3907
|
+
for (const item of extracted) {
|
|
3908
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
3909
|
+
throw new Error("variant verification item is invalid");
|
|
3910
|
+
const value = item;
|
|
3911
|
+
const key = typeof value.key === "string" ? value.key : "";
|
|
3912
|
+
const verdict = value.verdict;
|
|
3913
|
+
const confidence = Number(value.confidence);
|
|
3914
|
+
const reason = typeof value.reason === "string" ? value.reason.trim() : "";
|
|
3915
|
+
const candidate = byKey.get(key);
|
|
3916
|
+
if (!candidate || seen.has(key) || !["same", "separate", "uncertain"].includes(String(verdict))
|
|
3917
|
+
|| !Number.isFinite(confidence) || confidence < 0 || confidence > 1 || !reason) {
|
|
3918
|
+
throw new Error("variant verification item is incomplete");
|
|
3919
|
+
}
|
|
3920
|
+
seen.add(key);
|
|
3921
|
+
decisions.push({
|
|
3922
|
+
...candidate,
|
|
3923
|
+
verdict: verdict,
|
|
3924
|
+
confidence,
|
|
3925
|
+
reason
|
|
3926
|
+
});
|
|
3927
|
+
}
|
|
3928
|
+
if (seen.size !== candidateBatch.length)
|
|
3929
|
+
throw new Error("variant verification result omitted candidates");
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
catch (error) {
|
|
3933
|
+
if (error instanceof AppError && error.code === "TASK_CANCELLED")
|
|
3934
|
+
throw error;
|
|
3935
|
+
throw new AppError(502, "RELATIONSHIP_VARIANT_VERIFICATION_FAILED", "疑似人物名身份确认失败,未写入人物关系", {
|
|
3936
|
+
candidateCount: candidates.length,
|
|
3937
|
+
completedCallCount: callIds.length
|
|
3938
|
+
});
|
|
3939
|
+
}
|
|
3940
|
+
return { decisions, callIds };
|
|
3941
|
+
}
|
|
3942
|
+
async selectRelationshipSources(workId, scope, characters, selectedCharacterIds, modelId, taskId) {
|
|
3943
|
+
let generation;
|
|
3944
|
+
try {
|
|
3945
|
+
generation = await this.ensureRelationshipSearchIndex(workId);
|
|
3946
|
+
}
|
|
3947
|
+
catch {
|
|
3948
|
+
throw new AppError(503, "RELATIONSHIP_INDEX_BUILD_FAILED", "人物关系来源索引构建失败,请稍后重试");
|
|
3949
|
+
}
|
|
3950
|
+
const local = await this.localRelationshipSourceSelection(workId, scope, characters, selectedCharacterIds, generation);
|
|
3951
|
+
const verified = await this.verifyRelationshipVariantCandidates(workId, local.candidates, modelId, taskId);
|
|
3952
|
+
const accepted = verified.decisions.filter((decision) => decision.verdict === "same" && decision.confidence >= 0.8);
|
|
3953
|
+
const selectedKeys = new Set([...local.exactKeys, ...accepted.map((decision) => this.relationshipIndexedSourceKey(decision.sourceType, decision.sourceId))]);
|
|
3954
|
+
const chapters = [];
|
|
3955
|
+
const settings = [];
|
|
3956
|
+
for (const key of selectedKeys) {
|
|
3957
|
+
const ref = this.relationshipIndexedSourceRef(key);
|
|
3958
|
+
if (ref.sourceType === "chapter") {
|
|
3959
|
+
const source = this.relationshipIndexedSource(workId, ref.sourceType, ref.sourceId);
|
|
3960
|
+
if (source)
|
|
3961
|
+
chapters.push({
|
|
3962
|
+
id: source.sourceId,
|
|
3963
|
+
workId,
|
|
3964
|
+
title: source.title,
|
|
3965
|
+
content: collapseAiBlankLines(source.content),
|
|
3966
|
+
versionNo: Number(source.version)
|
|
3967
|
+
});
|
|
3968
|
+
}
|
|
3969
|
+
else {
|
|
3970
|
+
const source = this.relationshipSettingSource(workId, ref.sourceType, ref.sourceId);
|
|
3971
|
+
if (source)
|
|
3972
|
+
settings.push(source);
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
const chapterOrder = new Map(this.store.db.all(`SELECT chapter.id FROM chapters chapter JOIN volumes volume ON volume.id = chapter.volume_id
|
|
3976
|
+
WHERE chapter.work_id = ? ORDER BY volume.sort_order, chapter.sort_order`, workId).map((row, index) => [String(row.id), index]));
|
|
3977
|
+
chapters.sort((left, right) => (chapterOrder.get(String(left.id)) ?? Number.MAX_SAFE_INTEGER) - (chapterOrder.get(String(right.id)) ?? Number.MAX_SAFE_INTEGER));
|
|
3978
|
+
settings.sort((left, right) => `${left.sourceType}:${left.id}`.localeCompare(`${right.sourceType}:${right.id}`, "zh-CN"));
|
|
3979
|
+
return {
|
|
3980
|
+
generation,
|
|
3981
|
+
chapters,
|
|
3982
|
+
settings,
|
|
3983
|
+
variantDecisions: verified.decisions,
|
|
3984
|
+
verificationCallIds: verified.callIds,
|
|
3985
|
+
summary: {
|
|
3986
|
+
policyVersion: RELATIONSHIP_SEARCH_POLICY_VERSION,
|
|
3987
|
+
indexGeneration: generation,
|
|
3988
|
+
exactSourceCount: new Set(local.exactKeys).size,
|
|
3989
|
+
fuzzyCandidateCount: local.candidates.length,
|
|
3990
|
+
confirmedSourceCount: new Set(accepted.map((decision) => this.relationshipIndexedSourceKey(decision.sourceType, decision.sourceId))).size,
|
|
3991
|
+
rejectedSourceCount: verified.decisions.filter((decision) => decision.verdict === "separate").length,
|
|
3992
|
+
uncertainSourceCount: verified.decisions.filter((decision) => decision.verdict === "uncertain" || (decision.verdict === "same" && decision.confidence < 0.8)).length,
|
|
3993
|
+
reviewIds: []
|
|
3994
|
+
}
|
|
3995
|
+
};
|
|
3996
|
+
}
|
|
3997
|
+
relationshipSettingSource(workId, sourceType, sourceId) {
|
|
3998
|
+
const cleanStrings = (value) => {
|
|
3999
|
+
if (typeof value === "string")
|
|
4000
|
+
return collapseAiBlankLines(value);
|
|
4001
|
+
if (Array.isArray(value))
|
|
4002
|
+
return value.map(cleanStrings);
|
|
4003
|
+
if (!value || typeof value !== "object")
|
|
4004
|
+
return value;
|
|
4005
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cleanStrings(item)]));
|
|
4006
|
+
};
|
|
4007
|
+
const serialize = (value) => JSON.stringify(cleanStrings(value), null, 2);
|
|
4008
|
+
const source = (title, value, version) => ({
|
|
4009
|
+
id: sourceType === "setting" ? sourceId : `${sourceType}:${sourceId}`,
|
|
4010
|
+
title,
|
|
4011
|
+
sourceType,
|
|
4012
|
+
content: serialize(value),
|
|
4013
|
+
version: String(version ?? "")
|
|
4014
|
+
});
|
|
4015
|
+
try {
|
|
4016
|
+
if (sourceType === "work") {
|
|
4017
|
+
const item = this.store.getWork(sourceId);
|
|
4018
|
+
if (String(item.id) !== workId)
|
|
4019
|
+
return null;
|
|
4020
|
+
return source(`作品资料:${String(item.title)}`, {
|
|
4021
|
+
title: item.title, author: item.author, description: item.description, language: item.language
|
|
4022
|
+
}, item.versionNo);
|
|
4023
|
+
}
|
|
4024
|
+
if (sourceType === "setting") {
|
|
4025
|
+
const item = this.store.getSetting(sourceId);
|
|
4026
|
+
if (String(item.workId) !== workId)
|
|
4027
|
+
return null;
|
|
4028
|
+
return source(String(item.title), {
|
|
4029
|
+
category: item.category, content: item.content, tags: item.tags, status: item.status, authorNote: item.authorNote
|
|
4030
|
+
}, item.versionNo ?? item.updatedAt);
|
|
4031
|
+
}
|
|
4032
|
+
if (sourceType === "character") {
|
|
4033
|
+
const item = this.store.getCharacter(sourceId);
|
|
4034
|
+
if (String(item.workId) !== workId)
|
|
4035
|
+
return null;
|
|
4036
|
+
return source(`人物档案:${String(item.name)}`, {
|
|
4037
|
+
name: item.name, aliases: item.aliases, code: item.code, species: item.species, race: item.race,
|
|
4038
|
+
organizations: item.organizations, attributes: item.attributes, profile: item.profile,
|
|
4039
|
+
currentState: item.currentState, lockedFields: item.lockedFields
|
|
4040
|
+
}, item.versionNo);
|
|
4041
|
+
}
|
|
4042
|
+
if (sourceType === "race") {
|
|
4043
|
+
const item = this.store.getRace(sourceId);
|
|
4044
|
+
if (String(item.workId) !== workId)
|
|
4045
|
+
return null;
|
|
4046
|
+
return source(`种族设定:${String(item.name)}`, {
|
|
4047
|
+
name: item.name, description: item.description, lineage: item.lineage, settings: item.settings,
|
|
4048
|
+
effectiveSettings: item.effectiveSettings, members: item.members
|
|
4049
|
+
}, item.versionNo);
|
|
4050
|
+
}
|
|
4051
|
+
if (sourceType === "organization") {
|
|
4052
|
+
const item = this.store.getOrganization(sourceId);
|
|
4053
|
+
if (String(item.workId) !== workId)
|
|
4054
|
+
return null;
|
|
4055
|
+
return source(`组织设定:${String(item.name)}`, {
|
|
4056
|
+
name: item.name, description: item.description, settings: item.settings, members: item.members
|
|
4057
|
+
}, item.versionNo);
|
|
4058
|
+
}
|
|
4059
|
+
if (sourceType === "timeline-track") {
|
|
4060
|
+
const item = this.store.getTimelineTrack(sourceId);
|
|
4061
|
+
if (String(item.workId) !== workId)
|
|
4062
|
+
return null;
|
|
4063
|
+
return source(`时间轴:${String(item.name)}`, { name: item.name, description: item.description }, item.versionNo);
|
|
4064
|
+
}
|
|
4065
|
+
if (sourceType === "timeline-event") {
|
|
4066
|
+
const item = this.store.getTimelineEvent(sourceId);
|
|
4067
|
+
if (String(item.workId) !== workId)
|
|
4068
|
+
return null;
|
|
4069
|
+
return source(`时间线事件:${String(item.name)}`, {
|
|
4070
|
+
name: item.name,
|
|
4071
|
+
description: item.description,
|
|
4072
|
+
eventType: item.eventType,
|
|
4073
|
+
timeLabel: item.timeLabel,
|
|
4074
|
+
participants: (Array.isArray(item.participantIds) ? item.participantIds : []).map((characterId) => {
|
|
4075
|
+
try {
|
|
4076
|
+
return { characterId, name: this.store.getCharacter(String(characterId)).name };
|
|
4077
|
+
}
|
|
4078
|
+
catch {
|
|
4079
|
+
return { characterId, name: "已删除角色" };
|
|
4080
|
+
}
|
|
4081
|
+
}),
|
|
4082
|
+
location: item.location,
|
|
4083
|
+
causes: item.causes,
|
|
4084
|
+
impactScope: item.impactScope,
|
|
4085
|
+
evidence: item.evidence,
|
|
4086
|
+
status: item.status
|
|
4087
|
+
}, item.versionNo);
|
|
4088
|
+
}
|
|
4089
|
+
if (sourceType === "relationship") {
|
|
4090
|
+
const item = this.store.getRelationship(sourceId);
|
|
4091
|
+
if (String(item.workId) !== workId)
|
|
4092
|
+
return null;
|
|
4093
|
+
const fromName = String(this.store.getCharacter(String(item.fromCharacterId)).name);
|
|
4094
|
+
const toName = String(this.store.getCharacter(String(item.toCharacterId)).name);
|
|
4095
|
+
return source(`人物关系:${fromName} / ${toName}`, {
|
|
4096
|
+
fromCharacter: { id: item.fromCharacterId, name: fromName },
|
|
4097
|
+
toCharacter: { id: item.toCharacterId, name: toName },
|
|
4098
|
+
category: item.category,
|
|
4099
|
+
subtype: item.subtype,
|
|
4100
|
+
keywords: item.keywords,
|
|
4101
|
+
directed: item.directed,
|
|
4102
|
+
currentStatus: item.currentStatus,
|
|
4103
|
+
timeRange: item.timeRange,
|
|
4104
|
+
confidence: item.confidence,
|
|
4105
|
+
evidence: item.evidence,
|
|
4106
|
+
confirmationStatus: item.confirmationStatus,
|
|
4107
|
+
locked: item.locked
|
|
4108
|
+
}, item.versionNo);
|
|
4109
|
+
}
|
|
4110
|
+
if (sourceType === "chapter-outline") {
|
|
4111
|
+
const item = this.store.getChapterOutline(sourceId);
|
|
4112
|
+
if (!item || String(item.workId) !== workId)
|
|
4113
|
+
return null;
|
|
4114
|
+
const volumeTitle = String(this.store.getVolume(String(item.volumeId)).title);
|
|
4115
|
+
return source(`章节大纲:${volumeTitle} / ${String(item.chapterTitle)}`, {
|
|
4116
|
+
chapterTitle: item.chapterTitle,
|
|
4117
|
+
volumeTitle,
|
|
4118
|
+
goal: item.goal,
|
|
4119
|
+
conflict: item.conflict,
|
|
4120
|
+
turningPoint: item.turningPoint,
|
|
4121
|
+
notes: item.notes,
|
|
4122
|
+
status: item.status
|
|
4123
|
+
}, item.versionNo ?? item.updatedAt);
|
|
4124
|
+
}
|
|
4125
|
+
if (sourceType === "foreshadow") {
|
|
4126
|
+
const item = this.store.getForeshadow(sourceId);
|
|
4127
|
+
if (String(item.workId) !== workId)
|
|
4128
|
+
return null;
|
|
4129
|
+
return source(`伏笔:${String(item.title)}`, {
|
|
4130
|
+
title: item.title,
|
|
4131
|
+
description: item.description,
|
|
4132
|
+
status: item.status,
|
|
4133
|
+
importance: item.importance,
|
|
4134
|
+
resolutionNote: item.resolutionNote,
|
|
4135
|
+
occurrences: item.occurrences
|
|
4136
|
+
}, item.versionNo);
|
|
4137
|
+
}
|
|
4138
|
+
if (sourceType === "review") {
|
|
4139
|
+
const item = this.store.getReviewItem(sourceId);
|
|
4140
|
+
if (String(item.workId) !== workId)
|
|
4141
|
+
return null;
|
|
4142
|
+
return source(`审核项:${String(item.title)}`, {
|
|
4143
|
+
itemType: item.itemType,
|
|
4144
|
+
severity: item.severity,
|
|
4145
|
+
title: item.title,
|
|
4146
|
+
description: item.description,
|
|
4147
|
+
evidence: item.evidence,
|
|
4148
|
+
suggestion: item.suggestion,
|
|
4149
|
+
status: item.status,
|
|
4150
|
+
resolutionNote: item.resolutionNote
|
|
4151
|
+
}, item.updatedAt);
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
catch {
|
|
4155
|
+
return null;
|
|
4156
|
+
}
|
|
4157
|
+
return null;
|
|
4158
|
+
}
|
|
4159
|
+
relationshipSettingSources(workId, characters) {
|
|
4160
|
+
const refs = [
|
|
4161
|
+
["work", workId],
|
|
4162
|
+
...this.store.listSettings(workId).map((item) => ["setting", String(item.id)]),
|
|
4163
|
+
...characters.map((item) => ["character", String(item.id)]),
|
|
4164
|
+
...this.store.listRaces(workId).map((item) => ["race", String(item.id)]),
|
|
4165
|
+
...this.store.listOrganizations(workId).map((item) => ["organization", String(item.id)]),
|
|
4166
|
+
...this.store.listTimelineTracks(workId).map((item) => ["timeline-track", String(item.id)]),
|
|
4167
|
+
...this.store.listTimelineEvents(workId).map((item) => ["timeline-event", String(item.id)]),
|
|
4168
|
+
...this.store.listRelationships(workId).map((item) => ["relationship", String(item.id)]),
|
|
4169
|
+
...this.store.listChapterOutlines(workId).map((item) => ["chapter-outline", String(item.chapterId)]),
|
|
4170
|
+
...this.store.listForeshadows(workId).map((item) => ["foreshadow", String(item.id)]),
|
|
4171
|
+
...this.store.listReviewItems(workId).map((item) => ["review", String(item.id)])
|
|
4172
|
+
];
|
|
4173
|
+
return refs.flatMap(([sourceType, sourceId]) => {
|
|
4174
|
+
const materialized = this.relationshipSettingSource(workId, sourceType, sourceId);
|
|
4175
|
+
return materialized ? [materialized] : [];
|
|
4176
|
+
});
|
|
4177
|
+
}
|
|
3320
4178
|
async runRelationshipAnalysis(workId, scope, modelId, taskId) {
|
|
3321
4179
|
const characters = this.store.listCharacters(workId);
|
|
3322
4180
|
if (characters.length < 2)
|
|
3323
4181
|
throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
|
|
4182
|
+
const settingsOnly = scope.type === "settings";
|
|
3324
4183
|
const selectedCharacterIds = new Set(scope.characterIds ?? []);
|
|
3325
4184
|
for (const characterId of selectedCharacterIds) {
|
|
3326
4185
|
const character = characters.find((item) => item.id === characterId);
|
|
@@ -3332,33 +4191,86 @@ export class AiManager {
|
|
|
3332
4191
|
.filter((character) => selectedCharacterIds.has(String(character.id)))
|
|
3333
4192
|
.map((character) => `${String(character.id)} | ${String(character.name)}`)
|
|
3334
4193
|
.join("\n");
|
|
3335
|
-
const
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
const
|
|
4194
|
+
const sourceSelection = targeted
|
|
4195
|
+
? await this.selectRelationshipSources(workId, scope, characters, selectedCharacterIds, modelId, taskId)
|
|
4196
|
+
: null;
|
|
4197
|
+
const scopedChapters = targeted ? [] : settingsOnly ? [] : this.getScopeChapters(workId, scope);
|
|
4198
|
+
const chapters = sourceSelection?.chapters ?? scopedChapters;
|
|
4199
|
+
const availableSettings = targeted ? [] : settingsOnly || scope.includeAllSettings === true
|
|
4200
|
+
? this.relationshipSettingSources(workId, characters)
|
|
4201
|
+
: [];
|
|
4202
|
+
const settings = sourceSelection?.settings ?? availableSettings;
|
|
4203
|
+
if (!targeted && settingsOnly && availableSettings.length === 0)
|
|
4204
|
+
throw new AppError(409, "SETTINGS_REQUIRED", "人物关系分析范围内没有设定数据");
|
|
4205
|
+
if (!targeted && !settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
|
|
4206
|
+
throw new AppError(409, "RELATIONSHIP_SOURCES_REQUIRED", "人物关系分析范围内没有章节或设定数据");
|
|
4207
|
+
}
|
|
4208
|
+
const chunks = [
|
|
4209
|
+
...this.buildChapterChunks(chapters, 12_000).map((chunk) => ({ ...chunk, sourceKind: "chapter" })),
|
|
4210
|
+
...this.buildSettingChunks(settings, 12_000).map((chunk) => ({ ...chunk, sourceKind: "setting" }))
|
|
4211
|
+
];
|
|
4212
|
+
if (targeted && chunks.length === 0) {
|
|
4213
|
+
return {
|
|
4214
|
+
relationshipIds: [],
|
|
4215
|
+
candidateCount: 0,
|
|
4216
|
+
rawCandidateCount: 0,
|
|
4217
|
+
skipped: [{ index: -1, reason: "没有章节或设定数据命中被分析角色的名称或别名" }],
|
|
4218
|
+
batchCount: 0,
|
|
4219
|
+
coveredChapterCount: 0,
|
|
4220
|
+
coveredSettingCount: 0,
|
|
4221
|
+
fallbackSegmentCount: 0,
|
|
4222
|
+
policyOmittedSegmentCount: 0,
|
|
4223
|
+
targetedCharacterIds: [...selectedCharacterIds],
|
|
4224
|
+
targetedEvidenceCount: 0,
|
|
4225
|
+
aggregationBatchCount: 0,
|
|
4226
|
+
replacedRelationshipCount: 0,
|
|
4227
|
+
sourceSelection: sourceSelection?.summary,
|
|
4228
|
+
callIds: sourceSelection?.verificationCallIds ?? []
|
|
4229
|
+
};
|
|
4230
|
+
}
|
|
3339
4231
|
const concurrency = this.configuredConcurrency(workId, "relationship-analysis", modelId);
|
|
3340
4232
|
const roster = characters.map((character) => {
|
|
3341
4233
|
const aliases = character.aliases.filter((alias) => this.isSafeGlobalAlias(alias));
|
|
3342
4234
|
return `${String(character.id)} | ${String(character.name)}${aliases.length ? ` | 别名:${aliases.join("、")}` : ""}`;
|
|
3343
4235
|
}).join("\n");
|
|
3344
4236
|
const rawCandidates = [];
|
|
3345
|
-
const
|
|
3346
|
-
const
|
|
4237
|
+
const chapterEvidenceCandidates = [];
|
|
4238
|
+
const settingCandidates = [];
|
|
4239
|
+
const callIds = [...(sourceSelection?.verificationCallIds ?? [])];
|
|
4240
|
+
const settingsInstruction = [
|
|
4241
|
+
"你是小说人物关系设定抽取器,不是续写者。只根据本批系统设定数据抽取角色规范表中人物之间被明确写出的长期关系。",
|
|
4242
|
+
...(targeted ? ["被分析角色:", targetedRoster, "只输出至少一端属于被分析角色的关系。"] : []),
|
|
4243
|
+
"完整角色规范表:",
|
|
4244
|
+
roster,
|
|
4245
|
+
"硬规则:",
|
|
4246
|
+
"1. 本批 SETTING 条目是本次唯一事实来源;它可能来自作品设定、人物档案、种族、组织、时间线、已有关系、大纲、伏笔或审核项,不得引用未提供的数据或常识补全关系。",
|
|
4247
|
+
"2. 人名、别名、昵称和拼写变体必须归一到唯一 characterId,禁止创造角色或把相似名字强行合并。",
|
|
4248
|
+
"3. 只抽取条目明确陈述的长期亲属、社会、情感或冲突关系;同场出现、同属阵营、相似背景和推测性措辞不能生成关系。",
|
|
4249
|
+
"4. 父母→子女、君王→臣属、导师→学生、施害者→受害者、倾慕者→被倾慕者使用 directed=true;伴侣、朋友、手足、盟友、互为宿敌使用 directed=false。",
|
|
4250
|
+
"5. category 只能是 family、social、emotional、conflict、uncertain;confidence 低于 0.6 不输出。",
|
|
4251
|
+
"6. subtype 使用简短稳定中文词;同一人物对、同一 category、同一 subtype 只输出一次,不得输出反向重复边。",
|
|
4252
|
+
"7. keywords 提供 2 至 8 个描述双方互动、权力结构、情感阶段或剧情张力的中文关键词。",
|
|
4253
|
+
"8. 每条 evidence 必须提供 settingId、settingTitle、quote、supports;quote 必须是对应设定条目中的连续原文短句且不超过 80 字。",
|
|
4254
|
+
"9. evidence 的 quote 和 supports 必须能共同识别关系双方及关系类型,不能只凭一方名字或模糊代词建立关系。",
|
|
4255
|
+
"10. 输出 JSON 数组。字段:fromCharacterId、toCharacterId、category、subtype、keywords、directed、currentStatus、timeRange、confidence、evidence。没有明确关系时输出 []。"
|
|
4256
|
+
].join("\n");
|
|
4257
|
+
const extractChunk = async (chunk, maxAttempts = 3) => {
|
|
3347
4258
|
const generated = await this.generateTaggedJson({
|
|
3348
4259
|
workId,
|
|
3349
4260
|
taskId,
|
|
3350
4261
|
taskType: "relationship-analysis",
|
|
3351
4262
|
signal: this.taskSignal(taskId),
|
|
3352
4263
|
maxAttempts,
|
|
3353
|
-
scope:
|
|
3354
|
-
type: "
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
4264
|
+
scope: chunk.sourceKind === "setting"
|
|
4265
|
+
? { type: "settings", selection: chunk.text }
|
|
4266
|
+
: {
|
|
4267
|
+
type: "selection",
|
|
4268
|
+
selection: chunk.text,
|
|
4269
|
+
...(targeted ? { suppressAutomaticContext: true } : {})
|
|
4270
|
+
},
|
|
3359
4271
|
...(modelId ? { modelId } : {}),
|
|
3360
4272
|
parameters: { temperature: 0.1 },
|
|
3361
|
-
instruction: targeted ? [
|
|
4273
|
+
instruction: chunk.sourceKind === "setting" ? settingsInstruction : targeted ? [
|
|
3362
4274
|
"你是定向人物关系证据收集器。本阶段只建立跨章节证据账本,不下最终关系结论。",
|
|
3363
4275
|
"被分析角色:",
|
|
3364
4276
|
targetedRoster,
|
|
@@ -3403,9 +4315,11 @@ export class AiManager {
|
|
|
3403
4315
|
"24. 共同执行一次任务、同属一个组织、在同一集体场景中被感谢或落泪、替第三人转发消息,都不能单独证明同事、朋友或盟友。此类关系必须有原文明示身份,或至少两个不同章节的持续互动证据。"
|
|
3404
4316
|
].join("\n"),
|
|
3405
4317
|
extraSystemPrompt: [
|
|
3406
|
-
|
|
3407
|
-
? "
|
|
3408
|
-
:
|
|
4318
|
+
chunk.sourceKind === "setting"
|
|
4319
|
+
? "本次只允许使用提供的系统设定条目。每条结论都必须能回溯到对应 settingId 的原文引文。"
|
|
4320
|
+
: targeted
|
|
4321
|
+
? "你正在为指定角色收集可审计的跨章节关系线索。不得在证据收集阶段把单次互动直接判定为长期关系。"
|
|
4322
|
+
: "关系候选必须可审计。严禁把梦境伴侣、醉后梦话、单次约定、同章共现、礼称、同族归属、救援照护或类比提及写成现实长期关系。逐句校验说话人和关系方向。",
|
|
3409
4323
|
scope.additionalPrompt?.trim() ? `作者追加的关系分析提示:\n${scope.additionalPrompt.trim()}` : ""
|
|
3410
4324
|
].filter(Boolean).join("\n\n")
|
|
3411
4325
|
});
|
|
@@ -3419,26 +4333,32 @@ export class AiManager {
|
|
|
3419
4333
|
};
|
|
3420
4334
|
const chunkResults = await this.processChunks(chunks, concurrency, async (chunk) => {
|
|
3421
4335
|
if (taskId && this.store.getTask(taskId).status !== "running") {
|
|
3422
|
-
return { candidates: [], callIds: [], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
4336
|
+
return { sourceKind: chunk.sourceKind, candidates: [], callIds: [], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
3423
4337
|
}
|
|
3424
4338
|
try {
|
|
3425
|
-
const extracted = await extractChunk(chunk
|
|
3426
|
-
return { candidates: extracted.candidates, callIds: [extracted.callId], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
4339
|
+
const extracted = await extractChunk(chunk, 1);
|
|
4340
|
+
return { sourceKind: chunk.sourceKind, candidates: extracted.candidates, callIds: [extracted.callId], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
3427
4341
|
}
|
|
3428
4342
|
catch {
|
|
4343
|
+
if (chunk.sourceKind === "setting")
|
|
4344
|
+
throw new AppError(502, "AI_SETTINGS_BATCH_FAILED", "设定数据人物关系分析批次失败");
|
|
3429
4345
|
const segments = this.splitMarkedChapters(chunk.text);
|
|
3430
|
-
|
|
4346
|
+
const fallback = await this.runChapterSegmentFallback(segments, taskId, async (text, maxAttempts) => extractChunk({ sourceKind: "chapter", text }, maxAttempts), undefined, concurrency);
|
|
4347
|
+
return { sourceKind: chunk.sourceKind, ...fallback };
|
|
3431
4348
|
}
|
|
3432
4349
|
}, (completed) => {
|
|
3433
4350
|
if (taskId && this.store.getTask(taskId).status === "running") {
|
|
3434
|
-
const maximumProgress = targeted ? 72 : 92;
|
|
4351
|
+
const maximumProgress = targeted && !settingsOnly ? 72 : 92;
|
|
3435
4352
|
this.store.updateTask(taskId, { status: "running", progress: Math.min(maximumProgress, 5 + Math.round(completed / chunks.length * (maximumProgress - 5))) });
|
|
3436
4353
|
}
|
|
3437
4354
|
});
|
|
3438
4355
|
let fallbackSegmentCount = 0;
|
|
3439
4356
|
let policyOmittedSegmentCount = 0;
|
|
3440
4357
|
for (const result of chunkResults) {
|
|
3441
|
-
|
|
4358
|
+
if (result.sourceKind === "setting")
|
|
4359
|
+
settingCandidates.push(...result.candidates);
|
|
4360
|
+
else
|
|
4361
|
+
chapterEvidenceCandidates.push(...result.candidates);
|
|
3442
4362
|
callIds.push(...result.callIds);
|
|
3443
4363
|
fallbackSegmentCount += result.fallbackSegmentCount;
|
|
3444
4364
|
policyOmittedSegmentCount += result.policyOmittedSegmentCount;
|
|
@@ -3453,11 +4373,13 @@ export class AiManager {
|
|
|
3453
4373
|
batchCount: chunks.length
|
|
3454
4374
|
});
|
|
3455
4375
|
}
|
|
3456
|
-
|
|
4376
|
+
if (!targeted)
|
|
4377
|
+
rawCandidates.push(...chapterEvidenceCandidates, ...settingCandidates);
|
|
4378
|
+
const targetedEvidenceCount = targeted ? chapterEvidenceCandidates.length + settingCandidates.length : 0;
|
|
3457
4379
|
let aggregationBatchCount = 0;
|
|
3458
|
-
if (targeted &&
|
|
4380
|
+
if (targeted && !settingsOnly && chapterEvidenceCandidates.length > 0) {
|
|
3459
4381
|
const evidenceGroups = new Map();
|
|
3460
|
-
for (const evidence of
|
|
4382
|
+
for (const evidence of chapterEvidenceCandidates) {
|
|
3461
4383
|
const target = String(evidence.targetCharacterId ?? "");
|
|
3462
4384
|
const related = String(evidence.relatedCharacterId ?? evidence.relatedReference ?? "unknown");
|
|
3463
4385
|
const key = `${target}|${related}`;
|
|
@@ -3490,9 +4412,7 @@ export class AiManager {
|
|
|
3490
4412
|
maxAttempts: 2,
|
|
3491
4413
|
scope: {
|
|
3492
4414
|
type: "entities",
|
|
3493
|
-
|
|
3494
|
-
characterIds: [...selectedCharacterIds],
|
|
3495
|
-
excludeRelationshipConstraints: scope.replaceExistingRelationships === true
|
|
4415
|
+
suppressAutomaticContext: true
|
|
3496
4416
|
},
|
|
3497
4417
|
...(modelId ? { modelId } : {}),
|
|
3498
4418
|
parameters: { temperature: 0.1 },
|
|
@@ -3532,10 +4452,14 @@ export class AiManager {
|
|
|
3532
4452
|
this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 72 + Math.round(completed / evidenceBatches.length * 20)) });
|
|
3533
4453
|
}
|
|
3534
4454
|
});
|
|
3535
|
-
rawCandidates.
|
|
4455
|
+
rawCandidates.push(...aggregationResults.flatMap((result) => result.candidates));
|
|
3536
4456
|
callIds.push(...aggregationResults.map((result) => result.callId));
|
|
3537
4457
|
}
|
|
4458
|
+
if (targeted)
|
|
4459
|
+
rawCandidates.push(...settingCandidates);
|
|
3538
4460
|
const chapterById = new Map(chapters.map((chapter) => [String(chapter.id), chapter]));
|
|
4461
|
+
const settingById = new Map(settings.map((setting) => [String(setting.id), setting]));
|
|
4462
|
+
const relationshipEvidenceKey = (item) => `${String(item.settingId ?? item.chapterId)}|${String(item.quote)}`;
|
|
3539
4463
|
const categories = new Set(["family", "social", "emotional", "conflict", "uncertain"]);
|
|
3540
4464
|
const merged = new Map();
|
|
3541
4465
|
const skipped = [];
|
|
@@ -3584,21 +4508,36 @@ export class AiManager {
|
|
|
3584
4508
|
[fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
|
|
3585
4509
|
const evidence = (Array.isArray(candidate.evidence) ? candidate.evidence : [])
|
|
3586
4510
|
.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
|
3587
|
-
.
|
|
3588
|
-
if (typeof item.
|
|
3589
|
-
return
|
|
4511
|
+
.flatMap((item) => {
|
|
4512
|
+
if (typeof item.quote !== "string" || item.quote.trim().length > 80)
|
|
4513
|
+
return [];
|
|
4514
|
+
if (typeof item.settingId === "string") {
|
|
4515
|
+
const setting = settingById.get(item.settingId);
|
|
4516
|
+
if (!setting || !this.quoteExists(String(setting.content), item.quote))
|
|
4517
|
+
return [];
|
|
4518
|
+
return [{
|
|
4519
|
+
settingId: item.settingId,
|
|
4520
|
+
settingTitle: String(setting.title),
|
|
4521
|
+
quote: item.quote.trim(),
|
|
4522
|
+
contextType: "setting",
|
|
4523
|
+
supports: typeof item.supports === "string" ? item.supports : ""
|
|
4524
|
+
}];
|
|
4525
|
+
}
|
|
4526
|
+
if (typeof item.chapterId !== "string")
|
|
4527
|
+
return [];
|
|
3590
4528
|
const chapter = chapterById.get(item.chapterId);
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
4529
|
+
if (!chapter || !this.quoteExists(String(chapter.content), item.quote))
|
|
4530
|
+
return [];
|
|
4531
|
+
return [{
|
|
4532
|
+
chapterId: item.chapterId,
|
|
4533
|
+
chapterTitle: String(chapter.title),
|
|
4534
|
+
quote: item.quote.trim(),
|
|
4535
|
+
contextType: typeof item.contextType === "string" ? item.contextType : "current",
|
|
4536
|
+
supports: typeof item.supports === "string" ? item.supports : ""
|
|
4537
|
+
}];
|
|
4538
|
+
});
|
|
3600
4539
|
if (evidence.length === 0) {
|
|
3601
|
-
skipped.push({ index, reason: "
|
|
4540
|
+
skipped.push({ index, reason: "证据引文未在对应章节或设定条目命中" });
|
|
3602
4541
|
return;
|
|
3603
4542
|
}
|
|
3604
4543
|
const evidenceText = evidence.map((item) => String(item.quote)).join("\n");
|
|
@@ -3618,9 +4557,9 @@ export class AiManager {
|
|
|
3618
4557
|
}
|
|
3619
4558
|
}
|
|
3620
4559
|
if (category === "conflict" && subtype === "宿敌") {
|
|
3621
|
-
const
|
|
4560
|
+
const evidenceSources = new Set(evidence.map((item) => String(item.settingId ?? item.chapterId)));
|
|
3622
4561
|
const explicitlyLongRunning = /宿敌|世仇|死敌|多年|长期|世代|一直.{0,24}(?:敌|威胁|对抗|杀手)|远古.{0,16}(?:战|敌)|多次.{0,16}(?:交战|对抗|冲突)/u.test(evidenceText);
|
|
3623
|
-
if (
|
|
4562
|
+
if (evidenceSources.size < 2 && !explicitlyLongRunning)
|
|
3624
4563
|
subtype = "战时敌对";
|
|
3625
4564
|
}
|
|
3626
4565
|
const key = [fromCharacterId, toCharacterId, category, this.normalizeReference(subtype), directed ? "1" : "0"].join("|");
|
|
@@ -3629,9 +4568,9 @@ export class AiManager {
|
|
|
3629
4568
|
current.confidence = Math.max(current.confidence, confidence);
|
|
3630
4569
|
current.currentStatus = currentStatus || current.currentStatus;
|
|
3631
4570
|
current.keywords = [...new Set([...current.keywords, ...keywords])].slice(0, 8);
|
|
3632
|
-
const seenEvidence = new Set(current.evidence.map(
|
|
4571
|
+
const seenEvidence = new Set(current.evidence.map(relationshipEvidenceKey));
|
|
3633
4572
|
for (const item of evidence) {
|
|
3634
|
-
const evidenceKey =
|
|
4573
|
+
const evidenceKey = relationshipEvidenceKey(item);
|
|
3635
4574
|
if (!seenEvidence.has(evidenceKey))
|
|
3636
4575
|
current.evidence.push(item);
|
|
3637
4576
|
}
|
|
@@ -3656,20 +4595,30 @@ export class AiManager {
|
|
|
3656
4595
|
const durablePeerSubtype = /同事|同僚|共事|搭档|伙伴|朋友|好友|挚友|老友|旧友|战友|盟友|同盟|联盟/u.test(candidate.subtype);
|
|
3657
4596
|
if (candidate.category !== "social" || !durablePeerSubtype)
|
|
3658
4597
|
continue;
|
|
3659
|
-
const
|
|
4598
|
+
const evidenceSources = new Set(candidate.evidence.map((item) => String(item.settingId ?? item.chapterId)));
|
|
3660
4599
|
const evidenceText = candidate.evidence.map((item) => String(item.quote)).join("\n");
|
|
3661
4600
|
const explicitlyLongRunning = /同事|同僚|共事|搭档|伙伴|朋友|好友|挚友|老友|旧友|老朋友|战友|盟友|同盟|联盟|结盟|缔盟|盟约|旧识|好久不见|多年|长期|几十年|经常|往日|一直.{0,16}(?:合作|支援|互助|并肩)/u.test(evidenceText);
|
|
3662
|
-
if (
|
|
4601
|
+
if (evidenceSources.size >= 2 || explicitlyLongRunning)
|
|
3663
4602
|
continue;
|
|
3664
|
-
skipped.push({ index: -1, reason:
|
|
4603
|
+
skipped.push({ index: -1, reason: candidate.evidence.every((item) => item.contextType === "setting")
|
|
4604
|
+
? `“${candidate.subtype}”缺少设定集中的明确长期关系表述`
|
|
4605
|
+
: `“${candidate.subtype}”缺少明确身份或跨章长期互动证据` });
|
|
3665
4606
|
merged.delete(key);
|
|
3666
4607
|
}
|
|
3667
4608
|
const relationshipIds = [];
|
|
4609
|
+
const relationshipOutcomes = new Map();
|
|
4610
|
+
const recordRelationshipOutcome = (action, relationship) => {
|
|
4611
|
+
const relationshipId = String(relationship.id);
|
|
4612
|
+
const previous = relationshipOutcomes.get(relationshipId);
|
|
4613
|
+
relationshipOutcomes.set(relationshipId, {
|
|
4614
|
+
action: previous?.action === "created" ? "created" : action,
|
|
4615
|
+
relationship
|
|
4616
|
+
});
|
|
4617
|
+
};
|
|
3668
4618
|
let replacedRelationshipCount = 0;
|
|
4619
|
+
if (!this.taskCanCommit(taskId))
|
|
4620
|
+
return { interrupted: true, callIds };
|
|
3669
4621
|
this.store.db.transaction(() => {
|
|
3670
|
-
if (!targeted && scope.type === "book") {
|
|
3671
|
-
this.store.db.run("DELETE FROM relationships WHERE work_id = ? AND confirmation_status = 'pending' AND locked = 0", workId);
|
|
3672
|
-
}
|
|
3673
4622
|
if (targeted && scope.replaceExistingRelationships === true) {
|
|
3674
4623
|
const relationshipsToReplace = this.store.listRelationships(workId).filter((relationship) => selectedCharacterIds.has(String(relationship.fromCharacterId)) || selectedCharacterIds.has(String(relationship.toCharacterId)));
|
|
3675
4624
|
for (const relationship of relationshipsToReplace)
|
|
@@ -3677,6 +4626,7 @@ export class AiManager {
|
|
|
3677
4626
|
replacedRelationshipCount = relationshipsToReplace.length;
|
|
3678
4627
|
}
|
|
3679
4628
|
const existing = this.store.listRelationships(workId).filter((relationship) => relationship.confirmationStatus !== "rejected");
|
|
4629
|
+
const appendOnly = scope.replaceExistingRelationships !== true;
|
|
3680
4630
|
const unorderedPairKey = (fromCharacterId, toCharacterId) => {
|
|
3681
4631
|
const pair = [String(fromCharacterId), String(toCharacterId)].sort((left, right) => left.localeCompare(right));
|
|
3682
4632
|
return `${pair[0]}|${pair[1]}`;
|
|
@@ -3794,11 +4744,15 @@ export class AiManager {
|
|
|
3794
4744
|
});
|
|
3795
4745
|
if (duplicateIndex >= 0) {
|
|
3796
4746
|
const duplicate = existing[duplicateIndex];
|
|
4747
|
+
if (appendOnly) {
|
|
4748
|
+
skipped.push({ index: -1, reason: `已有相同的“${candidate.subtype}”关系,追加模式不更新` });
|
|
4749
|
+
continue;
|
|
4750
|
+
}
|
|
3797
4751
|
if (duplicate.confirmationStatus === "pending" && duplicate.locked !== true) {
|
|
3798
4752
|
const mergedEvidence = [...(duplicate.evidence ?? [])];
|
|
3799
|
-
const seenEvidence = new Set(mergedEvidence.map(
|
|
4753
|
+
const seenEvidence = new Set(mergedEvidence.map(relationshipEvidenceKey));
|
|
3800
4754
|
for (const item of candidate.evidence) {
|
|
3801
|
-
const evidenceKey =
|
|
4755
|
+
const evidenceKey = relationshipEvidenceKey(item);
|
|
3802
4756
|
if (!seenEvidence.has(evidenceKey))
|
|
3803
4757
|
mergedEvidence.push(item);
|
|
3804
4758
|
}
|
|
@@ -3810,6 +4764,10 @@ export class AiManager {
|
|
|
3810
4764
|
timeRange: candidate.timeRange,
|
|
3811
4765
|
evidence: mergedEvidence
|
|
3812
4766
|
}, "analysis", taskId ?? null, "AI 合并关系证据");
|
|
4767
|
+
recordRelationshipOutcome("updated", existing[duplicateIndex]);
|
|
4768
|
+
}
|
|
4769
|
+
else {
|
|
4770
|
+
recordRelationshipOutcome("unchanged", duplicate);
|
|
3813
4771
|
}
|
|
3814
4772
|
if (candidatePeerStrength > 0) {
|
|
3815
4773
|
for (let index = existing.length - 1; index >= 0; index -= 1) {
|
|
@@ -3840,12 +4798,12 @@ export class AiManager {
|
|
|
3840
4798
|
&& peerSocialStrength(relationship) > 0
|
|
3841
4799
|
&& peerSocialStrength(relationship) < candidatePeerStrength)
|
|
3842
4800
|
: -1;
|
|
3843
|
-
if (weakerExistingPeerIndex >= 0) {
|
|
4801
|
+
if (weakerExistingPeerIndex >= 0 && !appendOnly) {
|
|
3844
4802
|
const weaker = existing[weakerExistingPeerIndex];
|
|
3845
4803
|
const mergedEvidence = [...(weaker.evidence ?? [])];
|
|
3846
|
-
const seenEvidence = new Set(mergedEvidence.map(
|
|
4804
|
+
const seenEvidence = new Set(mergedEvidence.map(relationshipEvidenceKey));
|
|
3847
4805
|
for (const item of candidate.evidence) {
|
|
3848
|
-
const evidenceKey =
|
|
4806
|
+
const evidenceKey = relationshipEvidenceKey(item);
|
|
3849
4807
|
if (!seenEvidence.has(evidenceKey))
|
|
3850
4808
|
mergedEvidence.push(item);
|
|
3851
4809
|
}
|
|
@@ -3857,18 +4815,106 @@ export class AiManager {
|
|
|
3857
4815
|
timeRange: candidate.timeRange,
|
|
3858
4816
|
evidence: mergedEvidence
|
|
3859
4817
|
}, "analysis", taskId ?? null, "AI 更新关系强度");
|
|
4818
|
+
recordRelationshipOutcome("updated", existing[weakerExistingPeerIndex]);
|
|
3860
4819
|
continue;
|
|
3861
4820
|
}
|
|
3862
4821
|
const relationship = this.store.createRelationship(workId, { ...candidate, confirmationStatus: "pending", locked: false }, "analysis", taskId ?? null);
|
|
3863
4822
|
relationshipIds.push(String(relationship.id));
|
|
4823
|
+
recordRelationshipOutcome("created", relationship);
|
|
3864
4824
|
existing.push(relationship);
|
|
3865
4825
|
}
|
|
4826
|
+
if (taskId && settingsOnly)
|
|
4827
|
+
this.store.refreshTaskSourceVersions(taskId);
|
|
3866
4828
|
});
|
|
4829
|
+
if (sourceSelection) {
|
|
4830
|
+
const acceptedVariants = sourceSelection.variantDecisions.filter((decision) => decision.verdict === "same" && decision.confidence >= 0.8);
|
|
4831
|
+
const reviewIds = new Set();
|
|
4832
|
+
this.store.db.transaction(() => {
|
|
4833
|
+
for (const decision of acceptedVariants) {
|
|
4834
|
+
const observedIndex = decision.snippet.indexOf(decision.observed);
|
|
4835
|
+
const quote = observedIndex < 0
|
|
4836
|
+
? decision.snippet.slice(0, 160)
|
|
4837
|
+
: decision.snippet.slice(Math.max(0, observedIndex - 60), Math.min(decision.snippet.length, observedIndex + decision.observed.length + 60));
|
|
4838
|
+
const dedupeKey = this.store.hashContent([
|
|
4839
|
+
decision.targetCharacterId,
|
|
4840
|
+
normalizeRelationshipSearchText(decision.observed),
|
|
4841
|
+
decision.sourceType,
|
|
4842
|
+
decision.sourceId,
|
|
4843
|
+
decision.sourceVersion
|
|
4844
|
+
].join("|"));
|
|
4845
|
+
const review = this.store.createReviewItem(workId, {
|
|
4846
|
+
itemType: "character-name-variant",
|
|
4847
|
+
dedupeKey,
|
|
4848
|
+
severity: "medium",
|
|
4849
|
+
title: `疑似人物名错字:${decision.observed} → ${decision.targetName}`,
|
|
4850
|
+
description: `AI 判断来源“${decision.sourceTitle}”中的“${decision.observed}”可能指向人物“${decision.targetName}”。`,
|
|
4851
|
+
entityRefs: [{
|
|
4852
|
+
characterId: decision.targetCharacterId,
|
|
4853
|
+
sourceType: decision.sourceType,
|
|
4854
|
+
sourceId: decision.sourceId,
|
|
4855
|
+
sourceVersion: decision.sourceVersion
|
|
4856
|
+
}],
|
|
4857
|
+
evidence: [{
|
|
4858
|
+
sourceType: decision.sourceType,
|
|
4859
|
+
sourceId: decision.sourceId,
|
|
4860
|
+
sourceTitle: decision.sourceTitle,
|
|
4861
|
+
sourceVersion: decision.sourceVersion,
|
|
4862
|
+
observed: decision.observed,
|
|
4863
|
+
quote,
|
|
4864
|
+
confidence: decision.confidence,
|
|
4865
|
+
reason: decision.reason
|
|
4866
|
+
}],
|
|
4867
|
+
suggestion: `请核对“${decision.observed}”是否为“${decision.targetName}”的错别字;确认后再修改原文或登记别名。`,
|
|
4868
|
+
status: "pending"
|
|
4869
|
+
});
|
|
4870
|
+
reviewIds.add(String(review.id));
|
|
4871
|
+
}
|
|
4872
|
+
});
|
|
4873
|
+
sourceSelection.summary.reviewIds = [...reviewIds];
|
|
4874
|
+
}
|
|
4875
|
+
const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
|
|
4876
|
+
const relationshipResults = [...relationshipOutcomes.values()].map(({ action, relationship }) => {
|
|
4877
|
+
const evidence = Array.isArray(relationship.evidence)
|
|
4878
|
+
? relationship.evidence.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
|
4879
|
+
: [];
|
|
4880
|
+
return {
|
|
4881
|
+
relationshipId: String(relationship.id),
|
|
4882
|
+
action,
|
|
4883
|
+
fromCharacterId: String(relationship.fromCharacterId),
|
|
4884
|
+
fromCharacterName: characterNameById.get(String(relationship.fromCharacterId)) ?? String(relationship.fromCharacterId),
|
|
4885
|
+
toCharacterId: String(relationship.toCharacterId),
|
|
4886
|
+
toCharacterName: characterNameById.get(String(relationship.toCharacterId)) ?? String(relationship.toCharacterId),
|
|
4887
|
+
category: String(relationship.category),
|
|
4888
|
+
subtype: String(relationship.subtype),
|
|
4889
|
+
keywords: Array.isArray(relationship.keywords) ? relationship.keywords.map(String) : [],
|
|
4890
|
+
directed: Boolean(relationship.directed),
|
|
4891
|
+
currentStatus: String(relationship.currentStatus ?? ""),
|
|
4892
|
+
timeRange: relationship.timeRange && typeof relationship.timeRange === "object" && !Array.isArray(relationship.timeRange)
|
|
4893
|
+
? relationship.timeRange
|
|
4894
|
+
: {},
|
|
4895
|
+
confidence: Number(relationship.confidence ?? 0),
|
|
4896
|
+
confirmationStatus: String(relationship.confirmationStatus ?? "pending"),
|
|
4897
|
+
evidenceCount: evidence.length,
|
|
4898
|
+
evidence: evidence.slice(0, 3).map((item) => ({
|
|
4899
|
+
chapterId: String(item.chapterId ?? ""),
|
|
4900
|
+
chapterTitle: String(item.chapterTitle ?? chapterById.get(String(item.chapterId))?.title ?? ""),
|
|
4901
|
+
quote: String(item.quote ?? ""),
|
|
4902
|
+
supports: String(item.supports ?? "")
|
|
4903
|
+
})),
|
|
4904
|
+
evidenceTruncated: evidence.length > 3
|
|
4905
|
+
};
|
|
4906
|
+
});
|
|
4907
|
+
const createdCount = relationshipResults.filter((item) => item.action === "created").length;
|
|
4908
|
+
const updatedCount = relationshipResults.filter((item) => item.action === "updated").length;
|
|
4909
|
+
const unchangedCount = relationshipResults.filter((item) => item.action === "unchanged").length;
|
|
3867
4910
|
this.store.audit(workId, "relationship.analysis.completed", "work", workId, {
|
|
3868
4911
|
batchCount: chunks.length,
|
|
3869
4912
|
coveredChapterCount: chapters.length,
|
|
4913
|
+
coveredSettingCount: settings.length,
|
|
3870
4914
|
rawCandidateCount: rawCandidates.length,
|
|
3871
4915
|
savedCount: relationshipIds.length,
|
|
4916
|
+
updatedCount,
|
|
4917
|
+
unchangedCount,
|
|
3872
4918
|
skippedCount: skipped.length,
|
|
3873
4919
|
fallbackSegmentCount,
|
|
3874
4920
|
policyOmittedSegmentCount,
|
|
@@ -3881,16 +4927,32 @@ export class AiManager {
|
|
|
3881
4927
|
return {
|
|
3882
4928
|
relationshipIds,
|
|
3883
4929
|
candidateCount: relationshipIds.length,
|
|
4930
|
+
createdCount,
|
|
4931
|
+
updatedCount,
|
|
4932
|
+
unchangedCount,
|
|
4933
|
+
relationshipResults,
|
|
4934
|
+
analysisTarget: {
|
|
4935
|
+
mode: targeted ? "targeted-characters" : "all-relationships",
|
|
4936
|
+
scopeType: scope.type,
|
|
4937
|
+
characterIds: [...selectedCharacterIds],
|
|
4938
|
+
characterNames: characters
|
|
4939
|
+
.filter((character) => selectedCharacterIds.has(String(character.id)))
|
|
4940
|
+
.map((character) => String(character.name)),
|
|
4941
|
+
coveredChapterCount: chapters.length,
|
|
4942
|
+
includeAllSettings: scope.includeAllSettings === true
|
|
4943
|
+
},
|
|
3884
4944
|
rawCandidateCount: rawCandidates.length,
|
|
3885
4945
|
skipped,
|
|
3886
4946
|
batchCount: chunks.length,
|
|
3887
4947
|
coveredChapterCount: chapters.length,
|
|
4948
|
+
coveredSettingCount: settings.length,
|
|
3888
4949
|
fallbackSegmentCount,
|
|
3889
4950
|
policyOmittedSegmentCount,
|
|
3890
4951
|
targetedCharacterIds: [...selectedCharacterIds],
|
|
3891
4952
|
targetedEvidenceCount,
|
|
3892
4953
|
aggregationBatchCount,
|
|
3893
4954
|
replacedRelationshipCount,
|
|
4955
|
+
...(sourceSelection ? { sourceSelection: sourceSelection.summary } : {}),
|
|
3894
4956
|
callIds
|
|
3895
4957
|
};
|
|
3896
4958
|
}
|
|
@@ -3980,6 +5042,29 @@ export class AiManager {
|
|
|
3980
5042
|
flush();
|
|
3981
5043
|
return chunks;
|
|
3982
5044
|
}
|
|
5045
|
+
buildSettingChunks(settings, maximumChars = 10_000) {
|
|
5046
|
+
const chunks = [];
|
|
5047
|
+
let text = "";
|
|
5048
|
+
let settingIds = [];
|
|
5049
|
+
const flush = () => {
|
|
5050
|
+
if (settingIds.length === 0)
|
|
5051
|
+
return;
|
|
5052
|
+
chunks.push({ text, settingIds });
|
|
5053
|
+
text = "";
|
|
5054
|
+
settingIds = [];
|
|
5055
|
+
};
|
|
5056
|
+
for (const setting of settings) {
|
|
5057
|
+
const block = `<SETTING id="${String(setting.id)}" title="${String(setting.title).replaceAll('"', "'")}">\n${String(setting.content)}\n</SETTING>\n`;
|
|
5058
|
+
if (settingIds.length > 0 && text.length + block.length > maximumChars)
|
|
5059
|
+
flush();
|
|
5060
|
+
text += block;
|
|
5061
|
+
settingIds.push(String(setting.id));
|
|
5062
|
+
if (text.length >= maximumChars)
|
|
5063
|
+
flush();
|
|
5064
|
+
}
|
|
5065
|
+
flush();
|
|
5066
|
+
return chunks;
|
|
5067
|
+
}
|
|
3983
5068
|
splitMarkedChapters(text) {
|
|
3984
5069
|
const segments = text.match(/<CHAPTER\b[^>]*>[\s\S]*?<\/CHAPTER>/gu) ?? [];
|
|
3985
5070
|
return segments.length > 0 ? segments : [text];
|
|
@@ -4354,6 +5439,17 @@ export class AiManager {
|
|
|
4354
5439
|
this.assertAvailable(provider, model);
|
|
4355
5440
|
return { model, provider };
|
|
4356
5441
|
}
|
|
5442
|
+
analysisTaskModelPurpose(taskType) {
|
|
5443
|
+
if (taskType === "timeline-analysis")
|
|
5444
|
+
return "timeline-analysis";
|
|
5445
|
+
if (taskType === "relationship-analysis")
|
|
5446
|
+
return "relationship-analysis";
|
|
5447
|
+
if (taskType === "consistency-check")
|
|
5448
|
+
return "consistency-check";
|
|
5449
|
+
if (taskType === "chapter-analysis")
|
|
5450
|
+
return "chapter-analysis";
|
|
5451
|
+
return "book-analysis";
|
|
5452
|
+
}
|
|
4357
5453
|
configuredConcurrency(workId, taskType, modelId) {
|
|
4358
5454
|
const { provider } = this.resolveModel(workId, taskType, modelId);
|
|
4359
5455
|
return Math.round(clamp(numberValue(provider, "concurrency_limit") || 10, 1, 100));
|