@musnows/scriverse 0.5.4 → 0.5.6

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
@@ -5,7 +5,7 @@ import { logger, sanitizeError } from "./logger.js";
5
5
  import { paginated, paginationSql } from "./pagination.js";
6
6
  import { currentRequestActor } from "./request-context.js";
7
7
  import { fetchSafeAiEndpoint } from "./security.js";
8
- import { RELATIONSHIP_SEARCH_POLICY_VERSION, RelationshipApproximateMatchLimitError, findApproximateNameMatchesChunked, ftsPhrase, normalizeRelationshipSearchText, relationshipCharacterTokenText, relationshipCharacterTokens, relationshipPinyinTokenText, relationshipPinyinTokens } from "./relationship-search.js";
8
+ import { RELATIONSHIP_SEARCH_POLICY_VERSION, RelationshipApproximateMatchLimitError, findApproximateNameMatchesChunked, ftsPhrase, isRelationshipPhoneticReference, normalizeRelationshipSearchText, relationshipCharacterTokenText, relationshipCharacterTokens, relationshipPinyinTokenText, relationshipPinyinTokens } from "./relationship-search.js";
9
9
  import { clamp, id, json, maskSecret, now } from "./utils.js";
10
10
  import { z } from "zod";
11
11
  export function aiErrorForLog(error) {
@@ -26,6 +26,10 @@ const RELATIONSHIP_MAX_FUZZY_SOURCES = 200;
26
26
  const RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS = 4_000_000;
27
27
  const RELATIONSHIP_MAX_FUZZY_MATCHES = 600;
28
28
  const RELATIONSHIP_MAX_SOURCE_MATCHES = 256;
29
+ const RELATIONSHIP_PREFILTER_DISABLE_HINT = "请取消勾选“分析前按人物名称和拼音过滤来源”后重新预览";
30
+ function relationshipCandidateLimitMessage(message) {
31
+ return `${message};${RELATIONSHIP_PREFILTER_DISABLE_HINT}`;
32
+ }
29
33
  function isGeminiProviderOrModel(provider, model) {
30
34
  const endpoint = stringValue(provider, "base_url").toLowerCase();
31
35
  const modelId = stringValue(model, "model_id").toLowerCase();
@@ -319,10 +323,25 @@ export function resolveOutputTokens(usage, content) {
319
323
  }
320
324
  return estimateAiTokens(content);
321
325
  }
326
+ function reportedTokenCount(value) {
327
+ return typeof value === "number" && Number.isFinite(value)
328
+ ? Math.max(0, Math.round(value))
329
+ : null;
330
+ }
322
331
  function resolveInputCacheUsage(usage) {
323
332
  if (!usage || typeof usage !== "object")
324
333
  return null;
325
334
  const record = usage;
335
+ const anthropicCacheRead = reportedTokenCount(record.cache_read_input_tokens);
336
+ const anthropicCacheCreation = reportedTokenCount(record.cache_creation_input_tokens);
337
+ if (anthropicCacheRead !== null || anthropicCacheCreation !== null) {
338
+ const uncachedInputTokens = reportedTokenCount(record.input_tokens) ?? 0;
339
+ const cachedInputTokens = anthropicCacheRead ?? 0;
340
+ const inputTokens = uncachedInputTokens + cachedInputTokens + (anthropicCacheCreation ?? 0);
341
+ if (inputTokens <= 0)
342
+ return null;
343
+ return { inputTokens, cachedInputTokens };
344
+ }
326
345
  const promptDetails = record.prompt_tokens_details && typeof record.prompt_tokens_details === "object"
327
346
  ? record.prompt_tokens_details
328
347
  : {};
@@ -356,6 +375,37 @@ export function resolveCacheHitPercent(usage) {
356
375
  return undefined;
357
376
  return Math.round(resolved.cachedInputTokens / resolved.inputTokens * 1_000) / 10;
358
377
  }
378
+ export function resolveAiTokenUsage(usage, estimatedInputTokens, estimatedOutputTokens) {
379
+ const record = usage && typeof usage === "object" && !Array.isArray(usage)
380
+ ? usage
381
+ : {};
382
+ const reportedInputTokens = reportedTokenCount(record.prompt_tokens ?? record.input_tokens);
383
+ const reportedOutputTokens = reportedTokenCount(record.completion_tokens ?? record.output_tokens);
384
+ const cacheUsage = resolveInputCacheUsage(record);
385
+ const inputTokens = cacheUsage?.inputTokens
386
+ ?? reportedInputTokens
387
+ ?? Math.max(0, Math.round(estimatedInputTokens));
388
+ const outputTokens = reportedOutputTokens ?? Math.max(0, Math.round(estimatedOutputTokens));
389
+ return {
390
+ inputTokens,
391
+ outputTokens,
392
+ cachedInputTokens: cacheUsage?.cachedInputTokens ?? 0,
393
+ cacheEligibleInputTokens: cacheUsage?.inputTokens ?? 0,
394
+ source: reportedInputTokens !== null && reportedOutputTokens !== null
395
+ ? "reported"
396
+ : reportedInputTokens === null && reportedOutputTokens === null
397
+ ? "estimated"
398
+ : "mixed"
399
+ };
400
+ }
401
+ function completionPayloadOutputText(payload) {
402
+ const message = payload.choices?.[0]?.message;
403
+ return [
404
+ message?.reasoning_content ?? "",
405
+ message?.content ?? "",
406
+ ...(message?.tool_calls ?? []).map((toolCall) => `${toolCall.function.name}\n${String(toolCall.function.arguments ?? "")}`)
407
+ ].filter(Boolean).join("\n");
408
+ }
359
409
  function normalizeModelPreset(input, modelId = "") {
360
410
  const maxTokens = typeof input.max_tokens === "number" && Number.isFinite(input.max_tokens)
361
411
  ? Math.round(clamp(input.max_tokens, 1, 32_768))
@@ -878,6 +928,7 @@ export class AiManager {
878
928
  relationshipIndexBuilds = new Map();
879
929
  relationshipSelectionCache = new Map();
880
930
  relationshipSelectionBuilds = new Map();
931
+ relationshipIndexSyncTimers = new Map();
881
932
  relationshipIndexSerial = Promise.resolve();
882
933
  relationshipIndexTimer = null;
883
934
  relationshipIndexDisposed = false;
@@ -890,12 +941,101 @@ export class AiManager {
890
941
  this.authorizeTaskRun = authorizeTaskRun;
891
942
  this.contextBuilder = new ContextBuilder(store);
892
943
  this.store.setAnalysisTaskQueuedHandler((workId) => this.scheduleAutoRun(workId));
944
+ this.store.setRelationshipIndexQueuedHandler((workId) => this.scheduleRelationshipIndexSync(workId));
893
945
  this.relationshipIndexTimer = setTimeout(() => {
894
946
  this.relationshipIndexTimer = null;
895
947
  void this.schedulePendingRelationshipIndexes();
896
948
  }, 0);
897
949
  logger.info("ai.manager.ready");
898
950
  }
951
+ getPlatformTokenUsage(timezoneOffset) {
952
+ return this.getTokenUsage(null, timezoneOffset, true);
953
+ }
954
+ getWorkTokenUsage(workId, timezoneOffset) {
955
+ this.store.getWork(workId);
956
+ return this.getTokenUsage(workId, timezoneOffset, false);
957
+ }
958
+ getTokenUsage(workId, timezoneOffset, includeWorks) {
959
+ const scopeSql = workId === null ? "" : " AND call.work_id = ?";
960
+ const scopeParams = workId === null ? [] : [workId];
961
+ const usageFilter = "(call.input_tokens > 0 OR call.output_tokens > 0)";
962
+ const summary = this.store.db.get(`SELECT
963
+ COALESCE(SUM(call.input_tokens), 0) AS input_tokens,
964
+ COALESCE(SUM(call.output_tokens), 0) AS output_tokens,
965
+ COALESCE(SUM(call.cached_input_tokens), 0) AS cached_input_tokens,
966
+ COALESCE(SUM(call.cache_eligible_input_tokens), 0) AS cache_eligible_input_tokens,
967
+ COUNT(*) AS request_count,
968
+ COALESCE(SUM(CASE WHEN call.token_usage_source = 'reported' THEN 0 ELSE 1 END), 0) AS estimated_request_count,
969
+ MIN(call.created_at) AS first_used_at,
970
+ MAX(call.created_at) AS last_used_at
971
+ FROM ai_calls call
972
+ JOIN works work ON work.id = call.work_id
973
+ WHERE COALESCE(work.is_internal, 0) = 0 AND ${usageFilter}${scopeSql}`, ...scopeParams) ?? {};
974
+ const daily = this.store.db.all(`SELECT
975
+ date(call.created_at, printf('%+d minutes', ?)) AS usage_date,
976
+ COALESCE(SUM(call.input_tokens), 0) AS input_tokens,
977
+ COALESCE(SUM(call.output_tokens), 0) AS output_tokens,
978
+ COALESCE(SUM(call.cached_input_tokens), 0) AS cached_input_tokens,
979
+ COALESCE(SUM(call.cache_eligible_input_tokens), 0) AS cache_eligible_input_tokens,
980
+ COUNT(*) AS request_count,
981
+ COALESCE(SUM(CASE WHEN call.token_usage_source = 'reported' THEN 0 ELSE 1 END), 0) AS estimated_request_count
982
+ FROM ai_calls call
983
+ JOIN works work ON work.id = call.work_id
984
+ WHERE COALESCE(work.is_internal, 0) = 0 AND ${usageFilter}${scopeSql}
985
+ GROUP BY usage_date
986
+ ORDER BY usage_date`, timezoneOffset, ...scopeParams).map((row) => this.mapTokenUsageRow(row, { date: stringValue(row, "usage_date") }));
987
+ const works = includeWorks
988
+ ? this.store.db.all(`SELECT
989
+ work.id AS work_id,
990
+ work.title AS work_title,
991
+ COALESCE(SUM(call.input_tokens), 0) AS input_tokens,
992
+ COALESCE(SUM(call.output_tokens), 0) AS output_tokens,
993
+ COALESCE(SUM(call.cached_input_tokens), 0) AS cached_input_tokens,
994
+ COALESCE(SUM(call.cache_eligible_input_tokens), 0) AS cache_eligible_input_tokens,
995
+ COUNT(call.id) AS request_count,
996
+ COALESCE(SUM(CASE WHEN call.id IS NULL OR call.token_usage_source = 'reported' THEN 0 ELSE 1 END), 0) AS estimated_request_count,
997
+ MIN(call.created_at) AS first_used_at,
998
+ MAX(call.created_at) AS last_used_at
999
+ FROM works work
1000
+ LEFT JOIN ai_calls call ON call.work_id = work.id AND ${usageFilter}
1001
+ WHERE COALESCE(work.is_internal, 0) = 0
1002
+ GROUP BY work.id, work.title
1003
+ ORDER BY (COALESCE(SUM(call.input_tokens), 0) + COALESCE(SUM(call.output_tokens), 0)) DESC, work.title`).map((row) => this.mapTokenUsageRow(row, {
1004
+ workId: stringValue(row, "work_id"),
1005
+ workTitle: stringValue(row, "work_title"),
1006
+ firstUsedAt: row.first_used_at === null ? null : stringValue(row, "first_used_at"),
1007
+ lastUsedAt: row.last_used_at === null ? null : stringValue(row, "last_used_at")
1008
+ }))
1009
+ : undefined;
1010
+ return {
1011
+ summary: this.mapTokenUsageRow(summary, {
1012
+ firstUsedAt: summary.first_used_at === null || summary.first_used_at === undefined ? null : stringValue(summary, "first_used_at"),
1013
+ lastUsedAt: summary.last_used_at === null || summary.last_used_at === undefined ? null : stringValue(summary, "last_used_at")
1014
+ }),
1015
+ daily,
1016
+ ...(works ? { works } : {}),
1017
+ timezoneOffset
1018
+ };
1019
+ }
1020
+ mapTokenUsageRow(row, extra) {
1021
+ const inputTokens = numberValue(row, "input_tokens");
1022
+ const outputTokens = numberValue(row, "output_tokens");
1023
+ const cachedInputTokens = numberValue(row, "cached_input_tokens");
1024
+ const cacheEligibleInputTokens = numberValue(row, "cache_eligible_input_tokens");
1025
+ return {
1026
+ ...extra,
1027
+ totalTokens: inputTokens + outputTokens,
1028
+ inputTokens,
1029
+ outputTokens,
1030
+ cachedInputTokens,
1031
+ cacheEligibleInputTokens,
1032
+ cacheHitRate: cacheEligibleInputTokens > 0
1033
+ ? Math.round(cachedInputTokens / cacheEligibleInputTokens * 1_000) / 10
1034
+ : null,
1035
+ requestCount: numberValue(row, "request_count"),
1036
+ estimatedRequestCount: numberValue(row, "estimated_request_count")
1037
+ };
1038
+ }
899
1039
  resetAutoRunBatch(workId) {
900
1040
  this.autoRunBatches.set(workId, { claimed: 0, starting: new Set() });
901
1041
  }
@@ -946,10 +1086,14 @@ export class AiManager {
946
1086
  this.autoRunTimers.clear();
947
1087
  this.autoRunBatches.clear();
948
1088
  this.relationshipIndexDisposed = true;
1089
+ for (const timer of this.relationshipIndexSyncTimers.values())
1090
+ clearTimeout(timer);
1091
+ this.relationshipIndexSyncTimers.clear();
949
1092
  if (this.relationshipIndexTimer)
950
1093
  clearTimeout(this.relationshipIndexTimer);
951
1094
  this.relationshipIndexTimer = null;
952
1095
  this.store.setAnalysisTaskQueuedHandler(null);
1096
+ this.store.setRelationshipIndexQueuedHandler(null);
953
1097
  logger.info("ai.manager.disposed");
954
1098
  }
955
1099
  getAutoRunBatch(workId) {
@@ -1225,12 +1369,226 @@ export class AiManager {
1225
1369
  const modelId = input.modelId ?? (defaultRow ? stringValue(defaultRow, "model_id") : undefined);
1226
1370
  if (modelId)
1227
1371
  this.resolveModel(workId, modelPurpose, modelId);
1372
+ const relationshipScope = input.taskType === "relationship-analysis" && input.scope
1373
+ ? input.scope
1374
+ : null;
1375
+ if (relationshipScope && Array.isArray(relationshipScope.relationshipSourceRefs)) {
1376
+ this.relationshipSourcesFromRefs(workId, relationshipScope, this.store.listCharacters(workId), relationshipScope.relationshipSourceRefs);
1377
+ }
1228
1378
  return this.store.createTask(workId, {
1229
1379
  taskType: input.taskType,
1230
1380
  ...(input.scope ? { scope: input.scope } : {}),
1231
1381
  ...(modelId ? { modelId } : {})
1232
1382
  });
1233
1383
  }
1384
+ applyRelationshipChangePreview(taskId) {
1385
+ const task = this.store.getTask(taskId);
1386
+ if (task.taskType !== "relationship-analysis") {
1387
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_REQUIRED", "只有人物关系分析任务可以应用关系变更");
1388
+ }
1389
+ const result = this.store.getTaskStoredResult(taskId);
1390
+ const preview = result.relationshipChangePreview && typeof result.relationshipChangePreview === "object"
1391
+ && !Array.isArray(result.relationshipChangePreview)
1392
+ ? result.relationshipChangePreview
1393
+ : null;
1394
+ if (!preview)
1395
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_REQUIRED", "该任务没有待确认的关系变更");
1396
+ if (preview.status !== "pending") {
1397
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_NOT_PENDING", preview.status === "applied"
1398
+ ? "本次关系变更已经应用"
1399
+ : "本次关系变更已经放弃");
1400
+ }
1401
+ if (!this.store.isTaskSourceCurrent(taskId)) {
1402
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_SOURCE_CHANGED", "分析来源已发生变化,请重新运行分析后再应用");
1403
+ }
1404
+ const operations = Array.isArray(preview.operations)
1405
+ ? preview.operations.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)
1406
+ && ["created", "updated", "deleted"].includes(String(item.action)))
1407
+ : [];
1408
+ if (operations.length !== Number(preview.totalCount ?? operations.length) || operations.length > 5_000) {
1409
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_INVALID", "关系变更预览数据不完整,请重新运行分析");
1410
+ }
1411
+ const workId = String(task.workId);
1412
+ const relationshipInput = (snapshot) => ({
1413
+ fromCharacterId: String(snapshot.fromCharacterId),
1414
+ toCharacterId: String(snapshot.toCharacterId),
1415
+ category: String(snapshot.category),
1416
+ subtype: String(snapshot.subtype ?? ""),
1417
+ keywords: Array.isArray(snapshot.keywords) ? snapshot.keywords.map(String) : [],
1418
+ directed: snapshot.directed === true,
1419
+ currentStatus: String(snapshot.currentStatus ?? "active"),
1420
+ timeRange: snapshot.timeRange && typeof snapshot.timeRange === "object" && !Array.isArray(snapshot.timeRange)
1421
+ ? snapshot.timeRange
1422
+ : {},
1423
+ confidence: Number(snapshot.confidence ?? 0.5),
1424
+ evidence: Array.isArray(snapshot.evidence) ? snapshot.evidence : [],
1425
+ confirmationStatus: String(snapshot.confirmationStatus ?? "pending"),
1426
+ locked: snapshot.locked === true
1427
+ });
1428
+ return this.store.db.transaction(() => {
1429
+ const currentResult = this.store.getTaskStoredResult(taskId);
1430
+ const currentPreview = currentResult.relationshipChangePreview && typeof currentResult.relationshipChangePreview === "object"
1431
+ && !Array.isArray(currentResult.relationshipChangePreview)
1432
+ ? currentResult.relationshipChangePreview
1433
+ : null;
1434
+ if (currentPreview?.status !== "pending") {
1435
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_NOT_PENDING", currentPreview?.status === "applied"
1436
+ ? "本次关系变更已经应用"
1437
+ : "本次关系变更已经放弃");
1438
+ }
1439
+ for (const operation of operations) {
1440
+ if (operation.action === "created")
1441
+ continue;
1442
+ const before = operation.before;
1443
+ const expectedVersionNo = Number(operation.expectedVersionNo ?? before?.versionNo);
1444
+ let current;
1445
+ try {
1446
+ current = this.store.getRelationship(operation.relationshipId);
1447
+ }
1448
+ catch {
1449
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_STALE", "待处理的人物关系已经不存在,请重新运行分析", {
1450
+ relationshipId: operation.relationshipId
1451
+ });
1452
+ }
1453
+ if (String(current.workId) !== workId || !Number.isInteger(expectedVersionNo) || Number(current.versionNo) !== expectedVersionNo) {
1454
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_STALE", "人物关系已在预览后发生变化,请重新运行分析", {
1455
+ relationshipId: operation.relationshipId,
1456
+ expectedVersionNo,
1457
+ actualVersionNo: Number(current.versionNo)
1458
+ });
1459
+ }
1460
+ }
1461
+ const appliedResults = [];
1462
+ const appliedRelationshipIds = [];
1463
+ for (const operation of operations.filter((item) => item.action === "deleted")) {
1464
+ const before = operation.before;
1465
+ this.store.deleteRelationship(operation.relationshipId, Number(operation.expectedVersionNo ?? before.versionNo));
1466
+ appliedResults.push(this.relationshipResultSnapshot(workId, "deleted", before));
1467
+ }
1468
+ for (const operation of operations.filter((item) => item.action === "updated")) {
1469
+ const after = operation.after;
1470
+ const updated = this.store.updateRelationship(operation.relationshipId, relationshipInput(after), "analysis", taskId, "应用 AI 人物关系变更预览", Number(operation.expectedVersionNo ?? operation.before?.versionNo));
1471
+ appliedRelationshipIds.push(String(updated.id));
1472
+ appliedResults.push(this.relationshipResultSnapshot(workId, "updated", updated));
1473
+ }
1474
+ for (const operation of operations.filter((item) => item.action === "created")) {
1475
+ const created = this.store.createRelationship(workId, relationshipInput(operation.after), "analysis", taskId);
1476
+ appliedRelationshipIds.push(String(created.id));
1477
+ appliedResults.push(this.relationshipResultSnapshot(workId, "created", created));
1478
+ }
1479
+ const unchangedResults = Array.isArray(currentResult.relationshipResults)
1480
+ ? currentResult.relationshipResults.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)
1481
+ && item.action === "unchanged")
1482
+ : [];
1483
+ const appliedAt = now();
1484
+ const nextResult = {
1485
+ ...currentResult,
1486
+ relationshipIds: appliedRelationshipIds,
1487
+ candidateCount: appliedRelationshipIds.length,
1488
+ createdCount: operations.filter((item) => item.action === "created").length,
1489
+ updatedCount: operations.filter((item) => item.action === "updated").length,
1490
+ deletedCount: operations.filter((item) => item.action === "deleted").length,
1491
+ relationshipResults: [...appliedResults, ...unchangedResults],
1492
+ relationshipChangePreview: {
1493
+ ...currentPreview,
1494
+ status: "applied",
1495
+ appliedAt,
1496
+ appliedRelationshipIds
1497
+ }
1498
+ };
1499
+ const updatedTask = this.store.updateTask(taskId, { status: String(task.status), result: nextResult });
1500
+ this.store.audit(workId, "relationship.analysis.changes-applied", "analysis-task", taskId, {
1501
+ createdCount: nextResult.createdCount,
1502
+ updatedCount: nextResult.updatedCount,
1503
+ deletedCount: nextResult.deletedCount
1504
+ });
1505
+ logger.info("ai.relationship_changes.applied", {
1506
+ taskId,
1507
+ workId,
1508
+ createdCount: nextResult.createdCount,
1509
+ updatedCount: nextResult.updatedCount,
1510
+ deletedCount: nextResult.deletedCount
1511
+ });
1512
+ return updatedTask;
1513
+ });
1514
+ }
1515
+ discardRelationshipChangePreview(taskId) {
1516
+ const task = this.store.getTask(taskId);
1517
+ if (task.taskType !== "relationship-analysis") {
1518
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_REQUIRED", "只有人物关系分析任务可以放弃关系变更");
1519
+ }
1520
+ const result = this.store.getTaskStoredResult(taskId);
1521
+ const preview = result.relationshipChangePreview && typeof result.relationshipChangePreview === "object"
1522
+ && !Array.isArray(result.relationshipChangePreview)
1523
+ ? result.relationshipChangePreview
1524
+ : null;
1525
+ if (!preview)
1526
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_REQUIRED", "该任务没有待确认的关系变更");
1527
+ if (preview.status !== "pending") {
1528
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_NOT_PENDING", preview.status === "applied"
1529
+ ? "本次关系变更已经应用"
1530
+ : "本次关系变更已经放弃");
1531
+ }
1532
+ return this.store.db.transaction(() => {
1533
+ const currentResult = this.store.getTaskStoredResult(taskId);
1534
+ const currentPreview = currentResult.relationshipChangePreview && typeof currentResult.relationshipChangePreview === "object"
1535
+ && !Array.isArray(currentResult.relationshipChangePreview)
1536
+ ? currentResult.relationshipChangePreview
1537
+ : null;
1538
+ if (currentPreview?.status !== "pending") {
1539
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_NOT_PENDING", currentPreview?.status === "applied"
1540
+ ? "本次关系变更已经应用"
1541
+ : "本次关系变更已经放弃");
1542
+ }
1543
+ const discardedAt = now();
1544
+ const updatedTask = this.store.updateTask(taskId, {
1545
+ status: String(task.status),
1546
+ result: {
1547
+ ...currentResult,
1548
+ relationshipChangePreview: { ...currentPreview, status: "discarded", discardedAt }
1549
+ }
1550
+ });
1551
+ this.store.audit(String(task.workId), "relationship.analysis.changes-discarded", "analysis-task", taskId, {
1552
+ changeCount: Number(currentPreview.totalCount ?? 0)
1553
+ });
1554
+ logger.info("ai.relationship_changes.discarded", {
1555
+ taskId,
1556
+ workId: task.workId,
1557
+ changeCount: Number(currentPreview.totalCount ?? 0)
1558
+ });
1559
+ return updatedTask;
1560
+ });
1561
+ }
1562
+ rerunTask(taskId) {
1563
+ const original = this.store.getTask(taskId);
1564
+ const rerunnableStatuses = new Set(["review", "completed", "partial", "expired", "cancelled"]);
1565
+ if (!rerunnableStatuses.has(String(original.status))) {
1566
+ throw new AppError(409, "TASK_NOT_RERUNNABLE", "只有已结束的分析任务可以按原配置重跑");
1567
+ }
1568
+ const originalScope = original.scope && typeof original.scope === "object" && !Array.isArray(original.scope)
1569
+ ? original.scope
1570
+ : {};
1571
+ const { targetCharacters: _targetCharacters, relationshipSourceRefs: _relationshipSourceRefs, ...scope } = originalScope;
1572
+ const originalModel = original.model && typeof original.model === "object" && !Array.isArray(original.model)
1573
+ ? original.model
1574
+ : null;
1575
+ const modelId = typeof originalModel?.id === "string" ? originalModel.id : undefined;
1576
+ if (modelId)
1577
+ this.resolveModel(String(original.workId), this.analysisTaskModelPurpose(String(original.taskType)), modelId);
1578
+ const rerun = this.store.createTask(String(original.workId), {
1579
+ taskType: String(original.taskType),
1580
+ scope,
1581
+ ...(modelId ? { modelId } : {}),
1582
+ rerunOfTaskId: taskId
1583
+ });
1584
+ logger.info("ai.task.rerun_created", {
1585
+ taskId: rerun.id,
1586
+ originalTaskId: taskId,
1587
+ workId: original.workId,
1588
+ taskType: original.taskType
1589
+ });
1590
+ return { ...rerun, rerunOfTaskId: taskId };
1591
+ }
1234
1592
  async createSuggestion(input) {
1235
1593
  const action = input.taskType === "continue" ? "append" : input.taskType === "polish" ? "replace-selection" : "note";
1236
1594
  if (action === "replace-selection" && !input.scope.selection) {
@@ -1605,7 +1963,10 @@ export class AiManager {
1605
1963
  if (this.store.getTask(taskId).status !== "running")
1606
1964
  return this.store.getTask(taskId);
1607
1965
  const message = error instanceof Error ? error.message : "分析失败";
1608
- this.store.updateTask(taskId, { status: "partial", progress: 100, failures: [{ message }] });
1966
+ const failure = error instanceof AppError
1967
+ ? { message, code: error.code, ...(error.details === undefined ? {} : { details: error.details }) }
1968
+ : { message };
1969
+ this.store.updateTask(taskId, { status: "partial", progress: 100, failures: [failure] });
1609
1970
  logger.error("ai.task.failed", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000, error: aiErrorForLog(error) });
1610
1971
  throw error;
1611
1972
  }
@@ -2075,6 +2436,25 @@ export class AiManager {
2075
2436
  toolCount: tools.length
2076
2437
  });
2077
2438
  let activeApiKey = "";
2439
+ let trackedInputTokens = 0;
2440
+ let trackedOutputTokens = 0;
2441
+ let trackedCachedInputTokens = 0;
2442
+ let trackedCacheEligibleInputTokens = 0;
2443
+ const trackedUsageSources = new Set();
2444
+ const trackUsage = (usage) => {
2445
+ trackedInputTokens += usage.inputTokens;
2446
+ trackedOutputTokens += usage.outputTokens;
2447
+ trackedCachedInputTokens += usage.cachedInputTokens;
2448
+ trackedCacheEligibleInputTokens += usage.cacheEligibleInputTokens;
2449
+ trackedUsageSources.add(usage.source);
2450
+ };
2451
+ const trackedUsageSource = () => {
2452
+ if (trackedUsageSources.size === 1 && trackedUsageSources.has("reported"))
2453
+ return "reported";
2454
+ if (trackedUsageSources.size === 1 && trackedUsageSources.has("estimated"))
2455
+ return "estimated";
2456
+ return "mixed";
2457
+ };
2078
2458
  try {
2079
2459
  const apiKey = this.decryptKey(provider);
2080
2460
  activeApiKey = apiKey;
@@ -2166,6 +2546,8 @@ export class AiManager {
2166
2546
  totalInputTokens += cacheUsage.inputTokens;
2167
2547
  totalCachedInputTokens += cacheUsage.cachedInputTokens;
2168
2548
  }
2549
+ const outputText = completionPayloadOutputText(parsed);
2550
+ trackUsage(resolveAiTokenUsage(parsed.usage, estimateAiTokens(JSON.stringify(completionMessages)), outputText ? estimateAiTokens(outputText) : 0));
2169
2551
  return parsed;
2170
2552
  }
2171
2553
  catch {
@@ -2284,11 +2666,15 @@ export class AiManager {
2284
2666
  : "";
2285
2667
  throw new Error(`${protocol === "anthropic-messages" ? "Anthropic Messages" : "Chat Completions"} 响应缺少可用正文,finish_reason=${choice?.finish_reason ?? "unknown"}${suffix}`);
2286
2668
  }
2287
- this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
2288
2669
  const outputTokens = resolveOutputTokens(payload.usage, content);
2289
2670
  const cacheHitPercent = cacheUsageComplete && completionRequestCount > 0 && totalInputTokens > 0
2290
2671
  ? Math.round(totalCachedInputTokens / totalInputTokens * 1_000) / 10
2291
2672
  : undefined;
2673
+ this.store.db.run(`UPDATE ai_calls
2674
+ SET status = 'completed', output_chars = ?, input_tokens = ?, output_tokens = ?,
2675
+ cached_input_tokens = ?, cache_eligible_input_tokens = ?, cache_usage_available = ?,
2676
+ token_usage_source = ?, completed_at = ?
2677
+ WHERE id = ?`, content.length, trackedInputTokens, trackedOutputTokens, trackedCachedInputTokens, trackedCacheEligibleInputTokens, trackedCacheEligibleInputTokens > 0 ? 1 : 0, trackedUsageSource(), now(), callId);
2292
2678
  logger.info("ai.call.completed", {
2293
2679
  callId,
2294
2680
  workId: input.workId,
@@ -2303,7 +2689,11 @@ export class AiManager {
2303
2689
  }
2304
2690
  catch (error) {
2305
2691
  const message = error instanceof Error ? redactProviderSecret(error.message, activeApiKey) : "AI 调用失败";
2306
- this.store.db.run("UPDATE ai_calls SET status = 'failed', failure = ?, completed_at = ? WHERE id = ?", message, now(), callId);
2692
+ this.store.db.run(`UPDATE ai_calls
2693
+ SET status = 'failed', failure = ?, input_tokens = ?, output_tokens = ?,
2694
+ cached_input_tokens = ?, cache_eligible_input_tokens = ?, cache_usage_available = ?,
2695
+ token_usage_source = ?, completed_at = ?
2696
+ WHERE id = ?`, message, trackedInputTokens, trackedOutputTokens, trackedCachedInputTokens, trackedCacheEligibleInputTokens, trackedCacheEligibleInputTokens > 0 ? 1 : 0, trackedUsageSource(), now(), callId);
2307
2697
  logger.error("ai.call.failed", {
2308
2698
  callId,
2309
2699
  workId: input.workId,
@@ -2378,7 +2768,7 @@ export class AiManager {
2378
2768
  });
2379
2769
  if (!response.ok)
2380
2770
  return { ok: false, status: response.status, body: await response.text() };
2381
- const streamed = await this.readCompletionStream(response, protocol, (delta) => {
2771
+ const streamed = await this.readCompletionStream(response, protocol, estimateAiTokens(JSON.stringify(messages)), (delta) => {
2382
2772
  emitted = true;
2383
2773
  onDelta(delta);
2384
2774
  }, (delta) => {
@@ -2426,11 +2816,15 @@ export class AiManager {
2426
2816
  }
2427
2817
  if (streamedResult === null)
2428
2818
  throw lastFailure instanceof Error ? lastFailure : new Error("AI 流式请求重试后仍未返回响应");
2429
- const { content, reasoning, outputTokens, cacheHitPercent } = streamedResult;
2819
+ const { content, reasoning, outputTokens, cacheHitPercent, tokenUsage } = streamedResult;
2430
2820
  const processSteps = reasoning.trim()
2431
2821
  ? [{ id: thinkingStepId, type: "thinking", round: 1, content: reasoning, createdAt: thinkingCreatedAt }]
2432
2822
  : [];
2433
- this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
2823
+ this.store.db.run(`UPDATE ai_calls
2824
+ SET status = 'completed', output_chars = ?, input_tokens = ?, output_tokens = ?,
2825
+ cached_input_tokens = ?, cache_eligible_input_tokens = ?, cache_usage_available = ?,
2826
+ token_usage_source = ?, completed_at = ?
2827
+ WHERE id = ?`, content.length, tokenUsage.inputTokens, tokenUsage.outputTokens, tokenUsage.cachedInputTokens, tokenUsage.cacheEligibleInputTokens, tokenUsage.cacheEligibleInputTokens > 0 ? 1 : 0, tokenUsage.source, now(), callId);
2434
2828
  logger.info("ai.call.completed", {
2435
2829
  callId,
2436
2830
  workId: input.workId,
@@ -2456,7 +2850,7 @@ export class AiManager {
2456
2850
  throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message });
2457
2851
  }
2458
2852
  }
2459
- async readCompletionStream(response, protocol, onDelta, onThinkingDelta) {
2853
+ async readCompletionStream(response, protocol, estimatedInputTokens, onDelta, onThinkingDelta) {
2460
2854
  const protocolLabel = protocol === "anthropic-messages" ? "Anthropic Messages" : "Chat Completions";
2461
2855
  if (!response.body)
2462
2856
  throw new Error(`${protocolLabel} 流式响应缺少正文`);
@@ -2549,7 +2943,14 @@ export class AiManager {
2549
2943
  if (!content.trim())
2550
2944
  throw new Error(`${protocolLabel} 流式响应缺少可用正文,finish_reason=${finishReason}`);
2551
2945
  const cacheHitPercent = resolveCacheHitPercent(usage);
2552
- return { content, reasoning, outputTokens: resolveOutputTokens(usage, content), ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }) };
2946
+ const outputTokens = resolveOutputTokens(usage, content);
2947
+ return {
2948
+ content,
2949
+ reasoning,
2950
+ outputTokens,
2951
+ ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }),
2952
+ tokenUsage: resolveAiTokenUsage(usage, estimatedInputTokens, outputTokens)
2953
+ };
2553
2954
  }
2554
2955
  async runChapterAnalysis(workId, scope, modelId, taskId) {
2555
2956
  if (!scope.chapterId)
@@ -3445,6 +3846,104 @@ export class AiManager {
3445
3846
  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));
3446
3847
  await Promise.allSettled(workIds.map((workId) => this.ensureRelationshipSearchIndex(workId)));
3447
3848
  }
3849
+ scheduleRelationshipIndexSync(workId) {
3850
+ if (this.relationshipIndexDisposed)
3851
+ return;
3852
+ const existing = this.relationshipIndexSyncTimers.get(workId);
3853
+ if (existing)
3854
+ clearTimeout(existing);
3855
+ const timer = setTimeout(() => {
3856
+ this.relationshipIndexSyncTimers.delete(workId);
3857
+ try {
3858
+ const status = this.getRelationshipSearchIndexStatus(workId);
3859
+ if (Number(status.queuedSourceCount ?? 0) > 0) {
3860
+ void this.ensureRelationshipSearchIndex(workId).catch(() => undefined);
3861
+ }
3862
+ }
3863
+ catch {
3864
+ // 作品可能已在等待期间被删除
3865
+ }
3866
+ }, 2_000);
3867
+ this.relationshipIndexSyncTimers.set(workId, timer);
3868
+ logger.debug("relationship.search_index.auto_sync_scheduled", { workId });
3869
+ }
3870
+ getRelationshipSearchIndexStatus(workId) {
3871
+ this.store.getWork(workId);
3872
+ const row = this.store.db.get("SELECT status, generation, error, updated_at FROM relationship_source_index_state WHERE work_id = ?", workId);
3873
+ const queuedSources = this.store.db.all(`SELECT source_type, COUNT(*) AS count, MIN(queued_at) AS oldest_queued_at
3874
+ FROM relationship_source_index_queue WHERE work_id = ?
3875
+ GROUP BY source_type ORDER BY source_type`, workId).map((item) => ({
3876
+ sourceType: String(item.source_type),
3877
+ count: Number(item.count ?? 0),
3878
+ oldestQueuedAt: String(item.oldest_queued_at ?? "")
3879
+ }));
3880
+ const queuedSourceCount = queuedSources.reduce((total, item) => total + item.count, 0);
3881
+ const storedStatus = String(row?.status ?? "");
3882
+ const status = storedStatus === "building"
3883
+ ? "building"
3884
+ : queuedSourceCount > 0
3885
+ ? "queued"
3886
+ : storedStatus || "ready";
3887
+ return {
3888
+ workId,
3889
+ status,
3890
+ generation: Number(row?.generation ?? 0),
3891
+ queuedSourceCount,
3892
+ queuedSources,
3893
+ indexedSourceCount: Number(this.store.db.get("SELECT COUNT(*) AS count FROM relationship_source_search WHERE work_id = ?", workId)?.count ?? 0),
3894
+ indexedParagraphCount: Number(this.store.db.get(`SELECT COUNT(*) AS count FROM chapter_paragraph_pinyin_fts pinyin
3895
+ JOIN chapter_paragraph_search paragraph ON paragraph.id = pinyin.rowid
3896
+ WHERE paragraph.work_id = ?`, workId)?.count ?? 0),
3897
+ error: String(row?.error ?? ""),
3898
+ updatedAt: String(row?.updated_at ?? "")
3899
+ };
3900
+ }
3901
+ syncRelationshipSearchIndex(workId) {
3902
+ const status = this.getRelationshipSearchIndexStatus(workId);
3903
+ if (Number(status.queuedSourceCount ?? 0) > 0 || status.status === "building") {
3904
+ void this.ensureRelationshipSearchIndex(workId).catch(() => undefined);
3905
+ }
3906
+ return status;
3907
+ }
3908
+ rebuildRelationshipSearchIndex(workId) {
3909
+ this.store.getWork(workId);
3910
+ const timestamp = now();
3911
+ const queueSources = [
3912
+ { table: "works", sourceType: "work", idColumn: "id" },
3913
+ { table: "chapters", sourceType: "chapter", idColumn: "id" },
3914
+ { table: "settings", sourceType: "setting", idColumn: "id" },
3915
+ { table: "characters", sourceType: "character", idColumn: "id" },
3916
+ { table: "races", sourceType: "race", idColumn: "id" },
3917
+ { table: "organizations", sourceType: "organization", idColumn: "id" },
3918
+ { table: "timeline_tracks", sourceType: "timeline-track", idColumn: "id" },
3919
+ { table: "timeline_events", sourceType: "timeline-event", idColumn: "id" },
3920
+ { table: "relationships", sourceType: "relationship", idColumn: "id" },
3921
+ { table: "foreshadows", sourceType: "foreshadow", idColumn: "id" },
3922
+ { table: "review_items", sourceType: "review", idColumn: "id" }
3923
+ ];
3924
+ this.store.db.transaction(() => {
3925
+ this.store.db.run(`INSERT INTO relationship_source_index_queue(work_id, source_type, source_id, queued_at)
3926
+ SELECT work_id, source_type, source_id, ? FROM relationship_source_search WHERE work_id = ?
3927
+ ON CONFLICT(work_id, source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at`, timestamp, workId);
3928
+ for (const source of queueSources) {
3929
+ const workColumn = source.table === "works" ? "id" : "work_id";
3930
+ this.store.db.run(`INSERT INTO relationship_source_index_queue(work_id, source_type, source_id, queued_at)
3931
+ SELECT ${workColumn}, ?, ${source.idColumn}, ? FROM ${source.table} WHERE ${workColumn} = ?
3932
+ ON CONFLICT(work_id, source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at`, source.sourceType, timestamp, workId);
3933
+ }
3934
+ this.store.db.run(`INSERT INTO relationship_source_index_queue(work_id, source_type, source_id, queued_at)
3935
+ SELECT chapter.work_id, 'chapter-outline', outline.chapter_id, ?
3936
+ FROM chapter_outlines outline JOIN chapters chapter ON chapter.id = outline.chapter_id
3937
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL
3938
+ ON CONFLICT(work_id, source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at`, timestamp, workId);
3939
+ this.store.db.run(`INSERT INTO relationship_source_index_state(work_id, status, generation, error, updated_at)
3940
+ VALUES (?, 'queued', 0, '', ?)
3941
+ ON CONFLICT(work_id) DO UPDATE SET status = 'queued', error = '', updated_at = excluded.updated_at`, workId, timestamp);
3942
+ });
3943
+ const status = this.getRelationshipSearchIndexStatus(workId);
3944
+ void this.ensureRelationshipSearchIndex(workId).catch(() => undefined);
3945
+ return status;
3946
+ }
3448
3947
  ensureRelationshipSearchIndex(workId) {
3449
3948
  const existing = this.relationshipIndexBuilds.get(workId);
3450
3949
  if (existing)
@@ -3506,7 +4005,7 @@ export class AiManager {
3506
4005
  }
3507
4006
  }
3508
4007
  indexRelationshipChapter(workId, chapterId) {
3509
- const chapter = this.store.db.get("SELECT id FROM chapters WHERE id = ? AND work_id = ?", chapterId, workId);
4008
+ const chapter = this.store.db.get("SELECT id FROM chapters WHERE id = ? AND work_id = ? AND deleted_at IS NULL", chapterId, workId);
3510
4009
  if (!chapter)
3511
4010
  return;
3512
4011
  const paragraphs = this.store.db.all("SELECT id, search_content FROM chapter_paragraph_search WHERE chapter_id = ? ORDER BY paragraph_order", chapterId);
@@ -3556,10 +4055,10 @@ export class AiManager {
3556
4055
  const volume = this.store.getVolume(scope.volumeId);
3557
4056
  if (String(volume.workId) !== workId)
3558
4057
  throw new AppError(400, "VOLUME_WORK_MISMATCH", "分卷不属于当前作品");
3559
- return new Set(this.store.db.all(`SELECT id FROM chapters WHERE work_id = ? AND volume_id = ?
4058
+ return new Set(this.store.db.all(`SELECT id FROM chapters WHERE work_id = ? AND volume_id = ? AND deleted_at IS NULL
3560
4059
  AND excluded_from_analysis = 0 AND chapter_type <> '作者的话'`, workId, scope.volumeId).map((row) => String(row.id)));
3561
4060
  }
3562
- 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)));
4061
+ return new Set(this.store.db.all(`SELECT id FROM chapters WHERE work_id = ? AND deleted_at IS NULL AND excluded_from_analysis = 0 AND chapter_type <> '作者的话'`, workId).map((row) => String(row.id)));
3563
4062
  }
3564
4063
  relationshipIndexedSource(workId, sourceType, sourceId) {
3565
4064
  if (sourceType === "chapter") {
@@ -3622,7 +4121,6 @@ export class AiManager {
3622
4121
  }
3623
4122
  relationshipFuzzyIndexMatches(workId, reference, includeSettings, scope) {
3624
4123
  const result = new Set();
3625
- const characterTokens = [...new Set(relationshipCharacterTokens(reference))];
3626
4124
  const pinyinTokens = [...new Set(relationshipPinyinTokens(reference))];
3627
4125
  const score = new Map();
3628
4126
  const add = (key) => {
@@ -3634,14 +4132,14 @@ export class AiManager {
3634
4132
  ? {
3635
4133
  sql: `AND EXISTS (
3636
4134
  SELECT 1 FROM chapters chapter WHERE chapter.id = paragraph.chapter_id
3637
- AND chapter.volume_id = ? AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'
4135
+ AND chapter.deleted_at IS NULL AND chapter.volume_id = ? AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'
3638
4136
  )`,
3639
4137
  params: [scope.volumeId ?? ""]
3640
4138
  }
3641
4139
  : {
3642
4140
  sql: `AND EXISTS (
3643
4141
  SELECT 1 FROM chapters chapter WHERE chapter.id = paragraph.chapter_id
3644
- AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'
4142
+ AND chapter.deleted_at IS NULL AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'
3645
4143
  )`,
3646
4144
  params: []
3647
4145
  };
@@ -3666,45 +4164,57 @@ export class AiManager {
3666
4164
  result.add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
3667
4165
  }
3668
4166
  const normalizedCharacters = [...normalizeRelationshipSearchText(reference).trim()];
3669
- if (includeChapters) {
3670
- for (const character of [...new Set(normalizedCharacters)]) {
4167
+ const addSelectiveSignal = (keys) => {
4168
+ if (keys.size > RELATIONSHIP_MAX_FUZZY_SOURCES)
4169
+ return;
4170
+ for (const key of keys)
4171
+ add(key);
4172
+ };
4173
+ for (const character of [...new Set(normalizedCharacters)]) {
4174
+ const keys = new Set();
4175
+ if (includeChapters) {
3671
4176
  for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_short_terms term
3672
4177
  JOIN chapter_paragraph_search paragraph ON paragraph.id = term.paragraph_id
3673
4178
  WHERE paragraph.work_id = ? AND term.term = ? ${chapterScope.sql}
3674
4179
  LIMIT 201`, workId, character, ...chapterScope.params))
3675
- add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
4180
+ keys.add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
3676
4181
  }
3677
- for (const token of pinyinTokens) {
3678
- for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_pinyin_fts
3679
- JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_pinyin_fts.rowid
3680
- WHERE paragraph.work_id = ? AND chapter_paragraph_pinyin_fts MATCH ? ${chapterScope.sql}
3681
- LIMIT 201`, workId, token, ...chapterScope.params))
3682
- add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
3683
- }
3684
- }
3685
- if (includeSettings) {
3686
- for (const token of characterTokens) {
3687
- for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_exact_fts
4182
+ if (includeSettings) {
4183
+ const token = relationshipCharacterTokens(character)[0];
4184
+ if (token)
4185
+ for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_exact_fts
3688
4186
  JOIN relationship_source_search source ON source.id = relationship_source_exact_fts.rowid
3689
4187
  WHERE source.work_id = ? AND relationship_source_exact_fts MATCH ?
3690
- AND NOT (source.source_type = 'review' AND EXISTS (
4188
+ AND NOT (source.source_type = 'review' AND EXISTS (
3691
4189
  SELECT 1 FROM review_items review
3692
4190
  WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
3693
4191
  ))
3694
4192
  LIMIT 201`, workId, token))
3695
- add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
4193
+ keys.add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
4194
+ }
4195
+ addSelectiveSignal(keys);
4196
+ }
4197
+ for (const token of pinyinTokens) {
4198
+ const keys = new Set();
4199
+ if (includeChapters) {
4200
+ for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_pinyin_fts
4201
+ JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_pinyin_fts.rowid
4202
+ WHERE paragraph.work_id = ? AND chapter_paragraph_pinyin_fts MATCH ? ${chapterScope.sql}
4203
+ LIMIT 201`, workId, token, ...chapterScope.params))
4204
+ keys.add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
3696
4205
  }
3697
- for (const token of pinyinTokens) {
4206
+ if (includeSettings) {
3698
4207
  for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_pinyin_fts
3699
4208
  JOIN relationship_source_search source ON source.id = relationship_source_pinyin_fts.rowid
3700
4209
  WHERE source.work_id = ? AND relationship_source_pinyin_fts MATCH ?
3701
- AND NOT (source.source_type = 'review' AND EXISTS (
4210
+ AND NOT (source.source_type = 'review' AND EXISTS (
3702
4211
  SELECT 1 FROM review_items review
3703
4212
  WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
3704
4213
  ))
3705
4214
  LIMIT 201`, workId, token))
3706
- add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
4215
+ keys.add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
3707
4216
  }
4217
+ addSelectiveSignal(keys);
3708
4218
  }
3709
4219
  const threshold = Math.max(1, [...normalizeRelationshipSearchText(reference).trim()].length - 1);
3710
4220
  for (const [key, count] of score)
@@ -3725,6 +4235,7 @@ export class AiManager {
3725
4235
  String(character.code ?? ""),
3726
4236
  String(character.species ?? ""),
3727
4237
  String(character.race?.name ?? ""),
4238
+ String(character.attributes?.identity ?? ""),
3728
4239
  ...(Array.isArray(character.organizations) ? character.organizations.map((item) => String(item.name ?? "")) : []),
3729
4240
  ...[...relatedIds].flatMap((relatedId) => {
3730
4241
  try {
@@ -3774,16 +4285,19 @@ export class AiManager {
3774
4285
  for (const character of targetCharacters) {
3775
4286
  const targetCharacterId = String(character.id);
3776
4287
  const exactReferences = [...new Set([String(character.name), ...character.aliases].map((item) => item.trim()).filter(Boolean))];
3777
- const fuzzyReferenceCount = exactReferences.filter((reference) => [...normalizeRelationshipSearchText(reference).trim()].length >= 2).length;
4288
+ const anchors = this.relationshipIdentityAnchors(workId, character);
4289
+ const fuzzyReferenceCount = exactReferences.filter(isRelationshipPhoneticReference).length;
3778
4290
  if (fuzzyReferenceCount > RELATIONSHIP_MAX_FUZZY_REFERENCES) {
3779
- throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "人物名称和别名过多,无法在安全预算内完成疑似写法匹配", {
4291
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", relationshipCandidateLimitMessage("人物名称和别名过多,无法在安全预算内完成疑似写法匹配"), {
3780
4292
  characterId: targetCharacterId,
4293
+ targetName: String(character.name),
4294
+ reason: "registered-references",
3781
4295
  fuzzyReferenceCount,
3782
- maximumFuzzyReferences: RELATIONSHIP_MAX_FUZZY_REFERENCES
4296
+ maximumFuzzyReferences: RELATIONSHIP_MAX_FUZZY_REFERENCES,
4297
+ identityAnchorCount: anchors.length
3783
4298
  });
3784
4299
  }
3785
4300
  const normalizedExactReferences = new Set(exactReferences.map((item) => normalizeRelationshipSearchText(item).trim()));
3786
- const anchors = this.relationshipIdentityAnchors(workId, character);
3787
4301
  const anchorKeys = new Set();
3788
4302
  for (const anchor of anchors) {
3789
4303
  for (const chapterId of this.relationshipChapterExactMatches(workId, anchor)) {
@@ -3806,24 +4320,35 @@ export class AiManager {
3806
4320
  if (includeSettings)
3807
4321
  for (const key of this.relationshipSettingExactMatches(workId, reference))
3808
4322
  exactKeys.add(key);
4323
+ if (!isRelationshipPhoneticReference(reference))
4324
+ continue;
3809
4325
  const referenceLength = [...normalizeRelationshipSearchText(reference).trim()].length;
3810
4326
  if (referenceLength < 2)
3811
4327
  continue;
3812
- const fuzzyIndexKeys = referenceLength === 2
4328
+ const rawFuzzyIndexKeys = referenceLength === 2
3813
4329
  ? anchorKeys
3814
4330
  : this.relationshipFuzzyIndexMatches(workId, reference, includeSettings, scope);
4331
+ const fuzzyIndexKeys = rawFuzzyIndexKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES && anchorKeys.size > 0
4332
+ ? new Set([...rawFuzzyIndexKeys].filter((key) => anchorKeys.has(key)))
4333
+ : rawFuzzyIndexKeys;
3815
4334
  for (const key of fuzzyIndexKeys) {
3816
4335
  const ref = this.relationshipIndexedSourceRef(key);
3817
4336
  if (ref.sourceType === "chapter" && !allowedChapterIds.has(ref.sourceId))
3818
4337
  continue;
3819
4338
  if (ref.sourceType !== "chapter" && !includeSettings)
3820
4339
  continue;
4340
+ if (exactKeys.has(key))
4341
+ continue;
3821
4342
  targetIndexCandidateKeys.add(key);
3822
4343
  if (targetIndexCandidateKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES) {
3823
- throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名来源过多,请补充人物别名或身份资料后重试", {
4344
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", relationshipCandidateLimitMessage(`“${String(character.name)}”的拼音疑似来源仍然过多`), {
3824
4345
  characterId: targetCharacterId,
4346
+ targetName: String(character.name),
4347
+ reference,
4348
+ reason: "candidate-sources",
3825
4349
  candidateCount: targetIndexCandidateKeys.size,
3826
- maximum: RELATIONSHIP_MAX_FUZZY_SOURCES
4350
+ maximum: RELATIONSHIP_MAX_FUZZY_SOURCES,
4351
+ identityAnchorCount: anchors.length
3827
4352
  });
3828
4353
  }
3829
4354
  let indexed = loadedSources.get(key);
@@ -3840,10 +4365,14 @@ export class AiManager {
3840
4365
  const normalizedSearchable = normalizeRelationshipSearchText(searchable);
3841
4366
  fuzzyScanCharacters += normalizedSearchable.length;
3842
4367
  if (fuzzyScanCharacters > RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS) {
3843
- throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名待核对文本过多,请缩小分析范围或补充人物别名", {
4368
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", relationshipCandidateLimitMessage(`“${String(character.name)}”的拼音疑似来源待核对文本过多`), {
3844
4369
  characterId: targetCharacterId,
4370
+ targetName: String(character.name),
4371
+ reference,
4372
+ reason: "scan-characters",
3845
4373
  scannedCharacters: fuzzyScanCharacters,
3846
- maximumScannedCharacters: RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS
4374
+ maximumScannedCharacters: RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS,
4375
+ identityAnchorCount: anchors.length
3847
4376
  });
3848
4377
  }
3849
4378
  const referenceCharacters = [...normalizeRelationshipSearchText(reference).trim()];
@@ -3854,11 +4383,15 @@ export class AiManager {
3854
4383
  catch (error) {
3855
4384
  if (!(error instanceof RelationshipApproximateMatchLimitError))
3856
4385
  throw error;
3857
- throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "单个来源中的疑似人物名写法过多,请缩小分析范围或补充人物别名", {
4386
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", relationshipCandidateLimitMessage(`单个来源中“${String(character.name)}”的拼音疑似写法过多`), {
3858
4387
  characterId: targetCharacterId,
4388
+ targetName: String(character.name),
4389
+ reference,
4390
+ reason: "source-matches",
3859
4391
  sourceType: indexed.sourceType,
3860
4392
  sourceId: indexed.sourceId,
3861
- maximumSourceMatches: error.maximumCandidates
4393
+ maximumSourceMatches: error.maximumCandidates,
4394
+ identityAnchorCount: anchors.length
3862
4395
  });
3863
4396
  }
3864
4397
  for (const match of approximateMatches) {
@@ -3878,10 +4411,14 @@ export class AiManager {
3878
4411
  candidateOccurrences.set(occurrenceKey, occurrenceCount + 1);
3879
4412
  fuzzyMatchCount += 1;
3880
4413
  if (fuzzyMatchCount > RELATIONSHIP_MAX_FUZZY_MATCHES) {
3881
- throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名写法过多,请缩小分析范围或补充人物别名", {
4414
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", relationshipCandidateLimitMessage(`“${String(character.name)}”的拼音疑似写法仍然过多`), {
3882
4415
  characterId: targetCharacterId,
4416
+ targetName: String(character.name),
4417
+ reference,
4418
+ reason: "fuzzy-matches",
3883
4419
  fuzzyMatchCount,
3884
- maximumFuzzyMatches: RELATIONSHIP_MAX_FUZZY_MATCHES
4420
+ maximumFuzzyMatches: RELATIONSHIP_MAX_FUZZY_MATCHES,
4421
+ identityAnchorCount: anchors.length
3885
4422
  });
3886
4423
  }
3887
4424
  targetFuzzySourceKeys.add(key);
@@ -3905,10 +4442,13 @@ export class AiManager {
3905
4442
  }
3906
4443
  }
3907
4444
  if (targetFuzzySourceKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES) {
3908
- throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名来源过多,请补充人物别名或身份资料后重试", {
4445
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", relationshipCandidateLimitMessage(`“${String(character.name)}”的拼音疑似来源仍然过多`), {
3909
4446
  characterId: targetCharacterId,
4447
+ targetName: String(character.name),
4448
+ reason: "candidate-sources",
3910
4449
  candidateCount: targetFuzzySourceKeys.size,
3911
- maximum: RELATIONSHIP_MAX_FUZZY_SOURCES
4450
+ maximum: RELATIONSHIP_MAX_FUZZY_SOURCES,
4451
+ identityAnchorCount: anchors.length
3912
4452
  });
3913
4453
  }
3914
4454
  }
@@ -4062,13 +4602,17 @@ export class AiManager {
4062
4602
  }
4063
4603
  }
4064
4604
  const chapterOrder = new Map(this.store.db.all(`SELECT chapter.id FROM chapters chapter JOIN volumes volume ON volume.id = chapter.volume_id
4065
- WHERE chapter.work_id = ? ORDER BY volume.sort_order, chapter.sort_order`, workId).map((row, index) => [String(row.id), index]));
4605
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL ORDER BY volume.sort_order, chapter.sort_order`, workId).map((row, index) => [String(row.id), index]));
4066
4606
  chapters.sort((left, right) => (chapterOrder.get(String(left.id)) ?? Number.MAX_SAFE_INTEGER) - (chapterOrder.get(String(right.id)) ?? Number.MAX_SAFE_INTEGER));
4067
4607
  settings.sort((left, right) => `${left.sourceType}:${left.id}`.localeCompare(`${right.sourceType}:${right.id}`, "zh-CN"));
4608
+ const matchKinds = {};
4609
+ for (const key of selectedKeys)
4610
+ matchKinds[key] = local.exactKeys.includes(key) ? "exact" : "fuzzy";
4068
4611
  return {
4069
4612
  generation,
4070
4613
  chapters,
4071
4614
  settings,
4615
+ matchKinds,
4072
4616
  variantDecisions: verified.decisions,
4073
4617
  verificationCallIds: verified.callIds,
4074
4618
  summary: {
@@ -4096,6 +4640,7 @@ export class AiManager {
4096
4640
  const serialize = (value) => JSON.stringify(cleanStrings(value), null, 2);
4097
4641
  const source = (title, value, version) => ({
4098
4642
  id: sourceType === "setting" ? sourceId : `${sourceType}:${sourceId}`,
4643
+ sourceId,
4099
4644
  title,
4100
4645
  sourceType,
4101
4646
  content: serialize(value),
@@ -4264,11 +4809,174 @@ export class AiManager {
4264
4809
  return materialized ? [materialized] : [];
4265
4810
  });
4266
4811
  }
4812
+ relationshipChangeOperations(before, after) {
4813
+ const beforeById = new Map(before.map((relationship) => [String(relationship.id), relationship]));
4814
+ const afterById = new Map(after.map((relationship) => [String(relationship.id), relationship]));
4815
+ return [
4816
+ ...before.flatMap((relationship) => {
4817
+ const relationshipId = String(relationship.id);
4818
+ const next = afterById.get(relationshipId);
4819
+ if (!next) {
4820
+ return [{
4821
+ action: "deleted",
4822
+ relationshipId,
4823
+ expectedVersionNo: Number(relationship.versionNo),
4824
+ before: relationship
4825
+ }];
4826
+ }
4827
+ if (Number(next.versionNo) !== Number(relationship.versionNo)) {
4828
+ return [{
4829
+ action: "updated",
4830
+ relationshipId,
4831
+ expectedVersionNo: Number(relationship.versionNo),
4832
+ before: relationship,
4833
+ after: next
4834
+ }];
4835
+ }
4836
+ return [];
4837
+ }),
4838
+ ...after.flatMap((relationship) => {
4839
+ const relationshipId = String(relationship.id);
4840
+ return beforeById.has(relationshipId)
4841
+ ? []
4842
+ : [{ action: "created", relationshipId, after: relationship }];
4843
+ })
4844
+ ];
4845
+ }
4846
+ relationshipResultSnapshot(workId, action, relationship) {
4847
+ const evidence = Array.isArray(relationship.evidence)
4848
+ ? relationship.evidence.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
4849
+ : [];
4850
+ const characterName = (characterId) => {
4851
+ try {
4852
+ const character = this.store.getCharacter(String(characterId));
4853
+ return character.workId === workId ? String(character.name) : String(characterId);
4854
+ }
4855
+ catch {
4856
+ return String(characterId);
4857
+ }
4858
+ };
4859
+ return {
4860
+ relationshipId: String(relationship.id),
4861
+ action,
4862
+ fromCharacterId: String(relationship.fromCharacterId),
4863
+ fromCharacterName: characterName(relationship.fromCharacterId),
4864
+ toCharacterId: String(relationship.toCharacterId),
4865
+ toCharacterName: characterName(relationship.toCharacterId),
4866
+ category: String(relationship.category),
4867
+ subtype: String(relationship.subtype),
4868
+ keywords: Array.isArray(relationship.keywords) ? relationship.keywords.map(String) : [],
4869
+ directed: Boolean(relationship.directed),
4870
+ currentStatus: String(relationship.currentStatus ?? ""),
4871
+ timeRange: relationship.timeRange && typeof relationship.timeRange === "object" && !Array.isArray(relationship.timeRange)
4872
+ ? relationship.timeRange
4873
+ : {},
4874
+ confidence: Number(relationship.confidence ?? 0),
4875
+ confirmationStatus: String(relationship.confirmationStatus ?? "pending"),
4876
+ evidenceCount: evidence.length,
4877
+ evidence: evidence.slice(0, 3).map((item) => ({
4878
+ chapterId: String(item.chapterId ?? ""),
4879
+ chapterTitle: String(item.chapterTitle ?? item.settingTitle ?? ""),
4880
+ quote: String(item.quote ?? ""),
4881
+ supports: String(item.supports ?? "")
4882
+ })),
4883
+ evidenceTruncated: evidence.length > 3
4884
+ };
4885
+ }
4886
+ relationshipSourcesFromRefs(workId, scope, characters, refs) {
4887
+ const requestedRefs = new Map(refs.map((ref) => [
4888
+ this.relationshipIndexedSourceKey(ref.sourceType, ref.sourceId),
4889
+ ref
4890
+ ]));
4891
+ const availableChapters = scope.type === "settings" ? [] : this.getScopeChapters(workId, scope);
4892
+ const includeSettings = scope.type === "settings" || scope.includeAllSettings === true;
4893
+ const availableSettings = includeSettings ? this.relationshipSettingSources(workId, characters) : [];
4894
+ const availableVersions = new Map([
4895
+ ...availableChapters.map((chapter) => [
4896
+ this.relationshipIndexedSourceKey("chapter", String(chapter.id)),
4897
+ String(chapter.versionNo ?? "")
4898
+ ]),
4899
+ ...availableSettings.map((source) => [
4900
+ this.relationshipIndexedSourceKey(source.sourceType, source.sourceId),
4901
+ source.version
4902
+ ])
4903
+ ]);
4904
+ for (const [sourceKey, ref] of requestedRefs) {
4905
+ const currentVersion = availableVersions.get(sourceKey);
4906
+ if (currentVersion === undefined || currentVersion !== ref.sourceVersion) {
4907
+ throw new AppError(409, "RELATIONSHIP_SOURCE_PREVIEW_STALE", "来源已在预检后发生变化,请重新预览", {
4908
+ sourceType: ref.sourceType,
4909
+ sourceId: ref.sourceId
4910
+ });
4911
+ }
4912
+ }
4913
+ const chapters = availableChapters.filter((chapter) => requestedRefs.has(this.relationshipIndexedSourceKey("chapter", String(chapter.id))));
4914
+ const settings = availableSettings.filter((source) => requestedRefs.has(this.relationshipIndexedSourceKey(source.sourceType, source.sourceId)));
4915
+ return { chapters, settings };
4916
+ }
4917
+ async previewRelationshipSources(workId, scope, modelId) {
4918
+ const characters = this.store.listCharacters(workId);
4919
+ if (characters.length < 2)
4920
+ throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
4921
+ const selectedCharacterIds = new Set(scope.characterIds ?? []);
4922
+ if (selectedCharacterIds.size === 0) {
4923
+ throw new AppError(400, "RELATIONSHIP_PREVIEW_CHARACTERS_REQUIRED", "请先选择需要定向分析的角色");
4924
+ }
4925
+ for (const characterId of selectedCharacterIds) {
4926
+ if (!characters.some((character) => String(character.id) === characterId)) {
4927
+ throw new AppError(400, "CHARACTER_WORK_MISMATCH", "被分析角色不属于当前作品");
4928
+ }
4929
+ }
4930
+ const preFilterRelationshipSources = scope.preFilterRelationshipSources !== false;
4931
+ const sourceSelection = preFilterRelationshipSources
4932
+ ? await this.selectRelationshipSources(workId, scope, characters, selectedCharacterIds, modelId)
4933
+ : null;
4934
+ const chapters = sourceSelection?.chapters
4935
+ ?? (scope.type === "settings" ? [] : this.getScopeChapters(workId, scope));
4936
+ const settings = sourceSelection?.settings
4937
+ ?? (scope.type === "settings" || scope.includeAllSettings === true
4938
+ ? this.relationshipSettingSources(workId, characters)
4939
+ : []);
4940
+ const sources = [
4941
+ ...chapters.map((chapter) => ({
4942
+ sourceType: "chapter",
4943
+ sourceId: String(chapter.id),
4944
+ title: String(chapter.title),
4945
+ version: String(chapter.versionNo ?? ""),
4946
+ characterCount: String(chapter.content ?? "").length,
4947
+ matchType: sourceSelection?.matchKinds[this.relationshipIndexedSourceKey("chapter", String(chapter.id))] ?? "scope"
4948
+ })),
4949
+ ...settings.map((setting) => ({
4950
+ sourceType: setting.sourceType,
4951
+ sourceId: setting.sourceId,
4952
+ title: setting.title,
4953
+ version: setting.version,
4954
+ characterCount: setting.content.length,
4955
+ matchType: sourceSelection?.matchKinds[this.relationshipIndexedSourceKey(setting.sourceType, setting.sourceId)] ?? "scope"
4956
+ }))
4957
+ ];
4958
+ if (sources.length > 5_000) {
4959
+ throw new AppError(409, "RELATIONSHIP_SOURCE_PREVIEW_TOO_LARGE", "预检来源超过 5000 条,请缩小分析范围");
4960
+ }
4961
+ return {
4962
+ preFilterRelationshipSources,
4963
+ chapterCount: chapters.length,
4964
+ settingCount: settings.length,
4965
+ sourceCount: sources.length,
4966
+ totalCharacters: sources.reduce((total, source) => total + source.characterCount, 0),
4967
+ estimatedBatchCount: this.buildChapterChunks(chapters, 12_000).length + this.buildSettingChunks(settings, 12_000).length,
4968
+ sources,
4969
+ indexGeneration: sourceSelection?.generation ?? null,
4970
+ selectionSummary: sourceSelection?.summary ?? null,
4971
+ verificationCallCount: sourceSelection?.verificationCallIds.length ?? 0
4972
+ };
4973
+ }
4267
4974
  async runRelationshipAnalysis(workId, scope, modelId, taskId) {
4268
4975
  const characters = this.store.listCharacters(workId);
4269
4976
  if (characters.length < 2)
4270
4977
  throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
4271
4978
  const settingsOnly = scope.type === "settings";
4979
+ const includesSettings = settingsOnly || scope.includeAllSettings === true;
4272
4980
  const selectedCharacterIds = new Set(scope.characterIds ?? []);
4273
4981
  for (const characterId of selectedCharacterIds) {
4274
4982
  const character = characters.find((item) => item.id === characterId);
@@ -4276,19 +4984,24 @@ export class AiManager {
4276
4984
  throw new AppError(400, "CHARACTER_WORK_MISMATCH", "被分析角色不属于当前作品");
4277
4985
  }
4278
4986
  const targeted = selectedCharacterIds.size > 0;
4987
+ const preFilterRelationshipSources = targeted && scope.preFilterRelationshipSources !== false;
4988
+ const previewRelationshipChanges = scope.previewRelationshipChanges === true;
4279
4989
  const targetedRoster = characters
4280
4990
  .filter((character) => selectedCharacterIds.has(String(character.id)))
4281
4991
  .map((character) => `${String(character.id)} | ${String(character.name)}`)
4282
4992
  .join("\n");
4283
- const sourceSelection = targeted
4993
+ const previewedSources = Array.isArray(scope.relationshipSourceRefs)
4994
+ ? this.relationshipSourcesFromRefs(workId, scope, characters, scope.relationshipSourceRefs)
4995
+ : null;
4996
+ const sourceSelection = !previewedSources && preFilterRelationshipSources
4284
4997
  ? await this.selectRelationshipSources(workId, scope, characters, selectedCharacterIds, modelId, taskId)
4285
4998
  : null;
4286
- const scopedChapters = targeted ? [] : settingsOnly ? [] : this.getScopeChapters(workId, scope);
4287
- const chapters = sourceSelection?.chapters ?? scopedChapters;
4288
- const availableSettings = targeted ? [] : settingsOnly || scope.includeAllSettings === true
4999
+ const scopedChapters = preFilterRelationshipSources || settingsOnly ? [] : this.getScopeChapters(workId, scope);
5000
+ const chapters = previewedSources?.chapters ?? sourceSelection?.chapters ?? scopedChapters;
5001
+ const availableSettings = !preFilterRelationshipSources && (settingsOnly || scope.includeAllSettings === true)
4289
5002
  ? this.relationshipSettingSources(workId, characters)
4290
5003
  : [];
4291
- const settings = sourceSelection?.settings ?? availableSettings;
5004
+ const settings = previewedSources?.settings ?? sourceSelection?.settings ?? availableSettings;
4292
5005
  if (!targeted && settingsOnly && availableSettings.length === 0)
4293
5006
  throw new AppError(409, "SETTINGS_REQUIRED", "人物关系分析范围内没有设定数据");
4294
5007
  if (!targeted && !settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
@@ -4303,7 +5016,9 @@ export class AiManager {
4303
5016
  relationshipIds: [],
4304
5017
  candidateCount: 0,
4305
5018
  rawCandidateCount: 0,
4306
- skipped: [{ index: -1, reason: "没有章节或设定数据命中被分析角色的名称或别名" }],
5019
+ skipped: [{ index: -1, reason: preFilterRelationshipSources
5020
+ ? "没有章节或设定数据命中被分析角色的名称或别名"
5021
+ : "人物关系分析范围内没有章节或设定数据" }],
4307
5022
  batchCount: 0,
4308
5023
  coveredChapterCount: 0,
4309
5024
  coveredSettingCount: 0,
@@ -4313,6 +5028,19 @@ export class AiManager {
4313
5028
  targetedEvidenceCount: 0,
4314
5029
  aggregationBatchCount: 0,
4315
5030
  replacedRelationshipCount: 0,
5031
+ preFilterRelationshipSources,
5032
+ ...(previewRelationshipChanges ? {
5033
+ relationshipChangePreview: {
5034
+ status: "pending",
5035
+ totalCount: 0,
5036
+ createdCount: 0,
5037
+ updatedCount: 0,
5038
+ deletedCount: 0,
5039
+ generatedAt: now(),
5040
+ operations: []
5041
+ }
5042
+ } : {}),
5043
+ sourcePreviewApplied: Boolean(previewedSources),
4316
5044
  sourceSelection: sourceSelection?.summary,
4317
5045
  callIds: sourceSelection?.verificationCallIds ?? []
4318
5046
  };
@@ -4700,14 +5428,16 @@ export class AiManager {
4700
5428
  const relationshipId = String(relationship.id);
4701
5429
  const previous = relationshipOutcomes.get(relationshipId);
4702
5430
  relationshipOutcomes.set(relationshipId, {
4703
- action: previous?.action === "created" ? "created" : action,
5431
+ action: action === "deleted" ? "deleted" : previous?.action === "created" ? "created" : action,
4704
5432
  relationship
4705
5433
  });
4706
5434
  };
4707
5435
  let replacedRelationshipCount = 0;
5436
+ let relationshipChangeOperations = [];
5437
+ const relationshipsBeforePreview = previewRelationshipChanges ? this.store.listRelationships(workId) : [];
4708
5438
  if (!this.taskCanCommit(taskId))
4709
5439
  return { interrupted: true, callIds };
4710
- this.store.db.transaction(() => {
5440
+ const processRelationshipChanges = () => {
4711
5441
  if (targeted && scope.replaceExistingRelationships === true) {
4712
5442
  const relationshipsToReplace = this.store.listRelationships(workId).filter((relationship) => selectedCharacterIds.has(String(relationship.fromCharacterId)) || selectedCharacterIds.has(String(relationship.toCharacterId)));
4713
5443
  for (const relationship of relationshipsToReplace)
@@ -4912,9 +5642,31 @@ export class AiManager {
4912
5642
  recordRelationshipOutcome("created", relationship);
4913
5643
  existing.push(relationship);
4914
5644
  }
4915
- if (taskId && settingsOnly)
5645
+ if (taskId && includesSettings)
4916
5646
  this.store.refreshTaskSourceVersions(taskId);
4917
- });
5647
+ if (previewRelationshipChanges) {
5648
+ relationshipChangeOperations = this.relationshipChangeOperations(relationshipsBeforePreview, this.store.listRelationships(workId));
5649
+ }
5650
+ };
5651
+ if (previewRelationshipChanges)
5652
+ this.store.db.rollbackTransaction(processRelationshipChanges);
5653
+ else
5654
+ this.store.db.transaction(processRelationshipChanges);
5655
+ if (previewRelationshipChanges) {
5656
+ const unchangedOutcomes = [...relationshipOutcomes.values()].filter((outcome) => outcome.action === "unchanged");
5657
+ relationshipOutcomes.clear();
5658
+ for (const operation of relationshipChangeOperations) {
5659
+ recordRelationshipOutcome(operation.action, (operation.action === "deleted" ? operation.before : operation.after));
5660
+ }
5661
+ for (const outcome of unchangedOutcomes) {
5662
+ if (!relationshipOutcomes.has(String(outcome.relationship.id))) {
5663
+ recordRelationshipOutcome("unchanged", outcome.relationship);
5664
+ }
5665
+ }
5666
+ relationshipIds.length = 0;
5667
+ if (taskId && includesSettings)
5668
+ this.store.refreshTaskSourceVersions(taskId);
5669
+ }
4918
5670
  if (sourceSelection) {
4919
5671
  const acceptedVariants = sourceSelection.variantDecisions.filter((decision) => decision.verdict === "same" && decision.confidence >= 0.8);
4920
5672
  const reviewIds = new Set();
@@ -4961,48 +5713,21 @@ export class AiManager {
4961
5713
  });
4962
5714
  sourceSelection.summary.reviewIds = [...reviewIds];
4963
5715
  }
4964
- const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
4965
- const relationshipResults = [...relationshipOutcomes.values()].map(({ action, relationship }) => {
4966
- const evidence = Array.isArray(relationship.evidence)
4967
- ? relationship.evidence.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
4968
- : [];
4969
- return {
4970
- relationshipId: String(relationship.id),
4971
- action,
4972
- fromCharacterId: String(relationship.fromCharacterId),
4973
- fromCharacterName: characterNameById.get(String(relationship.fromCharacterId)) ?? String(relationship.fromCharacterId),
4974
- toCharacterId: String(relationship.toCharacterId),
4975
- toCharacterName: characterNameById.get(String(relationship.toCharacterId)) ?? String(relationship.toCharacterId),
4976
- category: String(relationship.category),
4977
- subtype: String(relationship.subtype),
4978
- keywords: Array.isArray(relationship.keywords) ? relationship.keywords.map(String) : [],
4979
- directed: Boolean(relationship.directed),
4980
- currentStatus: String(relationship.currentStatus ?? ""),
4981
- timeRange: relationship.timeRange && typeof relationship.timeRange === "object" && !Array.isArray(relationship.timeRange)
4982
- ? relationship.timeRange
4983
- : {},
4984
- confidence: Number(relationship.confidence ?? 0),
4985
- confirmationStatus: String(relationship.confirmationStatus ?? "pending"),
4986
- evidenceCount: evidence.length,
4987
- evidence: evidence.slice(0, 3).map((item) => ({
4988
- chapterId: String(item.chapterId ?? ""),
4989
- chapterTitle: String(item.chapterTitle ?? chapterById.get(String(item.chapterId))?.title ?? ""),
4990
- quote: String(item.quote ?? ""),
4991
- supports: String(item.supports ?? "")
4992
- })),
4993
- evidenceTruncated: evidence.length > 3
4994
- };
4995
- });
5716
+ if (previewRelationshipChanges && taskId && includesSettings)
5717
+ this.store.refreshTaskSourceVersions(taskId);
5718
+ const relationshipResults = [...relationshipOutcomes.values()].map(({ action, relationship }) => this.relationshipResultSnapshot(workId, action, relationship));
4996
5719
  const createdCount = relationshipResults.filter((item) => item.action === "created").length;
4997
5720
  const updatedCount = relationshipResults.filter((item) => item.action === "updated").length;
5721
+ const deletedCount = relationshipResults.filter((item) => item.action === "deleted").length;
4998
5722
  const unchangedCount = relationshipResults.filter((item) => item.action === "unchanged").length;
4999
- this.store.audit(workId, "relationship.analysis.completed", "work", workId, {
5723
+ this.store.audit(workId, previewRelationshipChanges ? "relationship.analysis.previewed" : "relationship.analysis.completed", "work", workId, {
5000
5724
  batchCount: chunks.length,
5001
5725
  coveredChapterCount: chapters.length,
5002
5726
  coveredSettingCount: settings.length,
5003
5727
  rawCandidateCount: rawCandidates.length,
5004
5728
  savedCount: relationshipIds.length,
5005
5729
  updatedCount,
5730
+ deletedCount,
5006
5731
  unchangedCount,
5007
5732
  skippedCount: skipped.length,
5008
5733
  fallbackSegmentCount,
@@ -5011,15 +5736,28 @@ export class AiManager {
5011
5736
  targetedCharacterCount: selectedCharacterIds.size,
5012
5737
  targetedEvidenceCount,
5013
5738
  aggregationBatchCount,
5014
- replacedRelationshipCount
5739
+ replacedRelationshipCount,
5740
+ preFilterRelationshipSources
5015
5741
  });
5016
5742
  return {
5017
5743
  relationshipIds,
5018
- candidateCount: relationshipIds.length,
5744
+ candidateCount: previewRelationshipChanges ? createdCount + updatedCount : relationshipIds.length,
5019
5745
  createdCount,
5020
5746
  updatedCount,
5747
+ deletedCount,
5021
5748
  unchangedCount,
5022
5749
  relationshipResults,
5750
+ ...(previewRelationshipChanges ? {
5751
+ relationshipChangePreview: {
5752
+ status: "pending",
5753
+ totalCount: relationshipChangeOperations.length,
5754
+ createdCount,
5755
+ updatedCount,
5756
+ deletedCount,
5757
+ generatedAt: now(),
5758
+ operations: relationshipChangeOperations
5759
+ }
5760
+ } : {}),
5023
5761
  analysisTarget: {
5024
5762
  mode: targeted ? "targeted-characters" : "all-relationships",
5025
5763
  scopeType: scope.type,
@@ -5028,7 +5766,8 @@ export class AiManager {
5028
5766
  .filter((character) => selectedCharacterIds.has(String(character.id)))
5029
5767
  .map((character) => String(character.name)),
5030
5768
  coveredChapterCount: chapters.length,
5031
- includeAllSettings: scope.includeAllSettings === true
5769
+ includeAllSettings: scope.includeAllSettings === true,
5770
+ preFilterRelationshipSources
5032
5771
  },
5033
5772
  rawCandidateCount: rawCandidates.length,
5034
5773
  skipped,
@@ -5041,6 +5780,8 @@ export class AiManager {
5041
5780
  targetedEvidenceCount,
5042
5781
  aggregationBatchCount,
5043
5782
  replacedRelationshipCount,
5783
+ preFilterRelationshipSources,
5784
+ sourcePreviewApplied: Boolean(previewedSources),
5044
5785
  ...(sourceSelection ? { sourceSelection: sourceSelection.summary } : {}),
5045
5786
  callIds
5046
5787
  };