@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/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 previewTaskTraceMessages(messages) {
41
- const records = messages.map(traceRecord);
42
- const contents = records.map((message) => message.content === null ? "" : String(message.content ?? ""));
43
- const allocations = contents.map(() => 0);
44
- let remaining = TASK_TRACE_PREVIEW_CHARACTER_LIMIT;
45
- let active = contents.map((_, index) => index).filter((index) => contents[index].length > 0);
46
- while (remaining > 0 && active.length > 0) {
47
- const share = Math.max(1, Math.floor(remaining / active.length));
48
- const next = [];
49
- for (const index of active) {
50
- if (remaining <= 0)
51
- break;
52
- const available = contents[index].length - allocations[index];
53
- const granted = Math.min(available, share, remaining);
54
- allocations[index] = allocations[index] + granted;
55
- remaining -= granted;
56
- if (allocations[index] < contents[index].length)
57
- next.push(index);
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
- active = next;
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 = scope.includeAllSettings ? availableSettings : availableSettings.filter((item) => item.locked);
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(`选定设定:\n${settings.map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`).join("\n")}`);
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 = /^(?:当前选中文本|当前章节|所在章节|作者主动引用的章节)/u.test(text);
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("SELECT m.*, p.name AS provider_name FROM models m JOIN providers p ON p.id = m.provider_id WHERE p.work_id = ? ORDER BY p.created_at, m.created_at", PLATFORM_AI_WORK_ID)
1103
- .map((row) => ({ ...this.mapModel(row), providerName: stringValue(row, "provider_name") }));
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 FROM models m JOIN providers p ON p.id = m.provider_id
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) => ({ ...this.mapModel(row), providerName: stringValue(row, "provider_name") })), pagination);
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.getTask(taskId);
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, full = false) {
1430
- this.store.getTask(taskId);
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: "preview",
1456
- previewLimit: TASK_TRACE_PREVIEW_CHARACTER_LIMIT,
1457
- truncated: preview.truncated,
1458
- totalPromptChars: preview.totalChars,
1486
+ mode: "full",
1459
1487
  trace: {
1460
- initialMessages: preview.messages,
1461
- rounds: rounds.map(summarizeTaskTraceRound),
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: modelId ?? null });
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, modelId, taskId);
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, modelId, taskId);
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, modelId, taskId);
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, modelId, taskId);
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, modelId, taskId);
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, modelId, taskId);
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, modelId, taskId);
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, modelId, taskId);
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
- ...(modelId ? { modelId } : {})
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 chapters = this.getScopeChapters(workId, scope);
3336
- if (chapters.length === 0)
3337
- throw new AppError(409, "CHAPTERS_REQUIRED", "人物关系分析范围内没有章节");
3338
- const chunks = this.buildChapterChunks(chapters, 12_000);
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 callIds = [];
3346
- const extractChunk = async (text, maxAttempts = 3) => {
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: "selection",
3355
- selection: text,
3356
- includeAllSettings: scope.includeAllSettings,
3357
- ...(targeted ? { characterIds: [...selectedCharacterIds], excludeRelationshipConstraints: scope.replaceExistingRelationships === true } : {})
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
- targeted
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.text, 1);
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
- return this.runChapterSegmentFallback(segments, taskId, extractChunk, undefined, concurrency);
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
- rawCandidates.push(...result.candidates);
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
- const targetedEvidenceCount = targeted ? rawCandidates.length : 0;
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 && rawCandidates.length > 0) {
4380
+ if (targeted && !settingsOnly && chapterEvidenceCandidates.length > 0) {
3459
4381
  const evidenceGroups = new Map();
3460
- for (const evidence of rawCandidates) {
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
- includeAllSettings: scope.includeAllSettings,
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.splice(0, rawCandidates.length, ...aggregationResults.flatMap((result) => result.candidates));
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
- .filter((item) => {
3588
- if (typeof item.chapterId !== "string" || typeof item.quote !== "string" || item.quote.trim().length > 80)
3589
- return false;
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
- return Boolean(chapter && this.quoteExists(String(chapter.content), item.quote));
3592
- })
3593
- .map((item) => ({
3594
- chapterId: item.chapterId,
3595
- chapterTitle: String(chapterById.get(String(item.chapterId))?.title ?? ""),
3596
- quote: String(item.quote).trim(),
3597
- contextType: typeof item.contextType === "string" ? item.contextType : "current",
3598
- supports: typeof item.supports === "string" ? item.supports : ""
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 evidenceChapters = new Set(evidence.map((item) => String(item.chapterId)));
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 (evidenceChapters.size < 2 && !explicitlyLongRunning)
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((item) => `${String(item.chapterId)}|${String(item.quote)}`));
4571
+ const seenEvidence = new Set(current.evidence.map(relationshipEvidenceKey));
3633
4572
  for (const item of evidence) {
3634
- const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
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 evidenceChapters = new Set(candidate.evidence.map((item) => String(item.chapterId)));
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 (evidenceChapters.size >= 2 || explicitlyLongRunning)
4601
+ if (evidenceSources.size >= 2 || explicitlyLongRunning)
3663
4602
  continue;
3664
- skipped.push({ index: -1, reason: `“${candidate.subtype}”缺少明确身份或跨章长期互动证据` });
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((item) => `${String(item.chapterId)}|${String(item.quote)}`));
4753
+ const seenEvidence = new Set(mergedEvidence.map(relationshipEvidenceKey));
3800
4754
  for (const item of candidate.evidence) {
3801
- const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
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((item) => `${String(item.chapterId)}|${String(item.quote)}`));
4804
+ const seenEvidence = new Set(mergedEvidence.map(relationshipEvidenceKey));
3847
4805
  for (const item of candidate.evidence) {
3848
- const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
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));