@musnows/scriverse 0.5.2 → 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();
@@ -850,18 +856,30 @@ export class AiManager {
850
856
  vault;
851
857
  fetchImpl;
852
858
  validateOutboundUrl;
859
+ authorizeTaskRun;
853
860
  contextBuilder;
854
861
  taskControllers = new Map();
855
862
  autoRunBatches = new Map();
856
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;
857
870
  providerSchedules = new Map();
858
- constructor(store, vault, fetchImpl = fetch, validateOutboundUrl) {
871
+ constructor(store, vault, fetchImpl = fetch, validateOutboundUrl, authorizeTaskRun) {
859
872
  this.store = store;
860
873
  this.vault = vault;
861
874
  this.fetchImpl = fetchImpl;
862
875
  this.validateOutboundUrl = validateOutboundUrl;
876
+ this.authorizeTaskRun = authorizeTaskRun;
863
877
  this.contextBuilder = new ContextBuilder(store);
864
878
  this.store.setAnalysisTaskQueuedHandler((workId) => this.scheduleAutoRun(workId));
879
+ this.relationshipIndexTimer = setTimeout(() => {
880
+ this.relationshipIndexTimer = null;
881
+ void this.schedulePendingRelationshipIndexes();
882
+ }, 0);
865
883
  logger.info("ai.manager.ready");
866
884
  }
867
885
  resetAutoRunBatch(workId) {
@@ -913,6 +931,10 @@ export class AiManager {
913
931
  clearTimeout(timer);
914
932
  this.autoRunTimers.clear();
915
933
  this.autoRunBatches.clear();
934
+ this.relationshipIndexDisposed = true;
935
+ if (this.relationshipIndexTimer)
936
+ clearTimeout(this.relationshipIndexTimer);
937
+ this.relationshipIndexTimer = null;
916
938
  this.store.setAnalysisTaskQueuedHandler(null);
917
939
  logger.info("ai.manager.disposed");
918
940
  }
@@ -1091,14 +1113,27 @@ export class AiManager {
1091
1113
  }
1092
1114
  listPlatformModels() {
1093
1115
  return this.store.db
1094
- .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)
1095
- .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
+ }));
1096
1125
  }
1097
1126
  listPlatformModelsPage(pagination) {
1098
1127
  const page = paginationSql(pagination);
1099
- 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
1100
1130
  WHERE p.work_id = ? ORDER BY p.created_at, m.created_at${page.sql}`, PLATFORM_AI_WORK_ID, ...page.params);
1101
- 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);
1102
1137
  }
1103
1138
  listWorkModels(workId) {
1104
1139
  this.store.getWork(workId);
@@ -1150,6 +1185,19 @@ export class AiManager {
1150
1185
  model: this.getModel(stringValue(row, "model_id"))
1151
1186
  })), pagination);
1152
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
+ }
1153
1201
  async createSuggestion(input) {
1154
1202
  const action = input.taskType === "continue" ? "append" : input.taskType === "polish" ? "replace-selection" : "note";
1155
1203
  if (action === "replace-selection" && !input.scope.selection) {
@@ -1444,12 +1492,17 @@ export class AiManager {
1444
1492
  }
1445
1493
  };
1446
1494
  }
1447
- async runTask(taskId, modelId) {
1495
+ async runTask(taskId, modelId, actor) {
1448
1496
  const task = this.store.getTask(taskId);
1497
+ this.authorizeTaskRun?.(task, actor);
1449
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);
1450
1503
  const batch = this.getAutoRunBatch(workId);
1451
1504
  const startedAt = process.hrtime.bigint();
1452
- 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 });
1453
1506
  if (task.status !== "pending")
1454
1507
  throw new AppError(409, "TASK_NOT_PENDING", "只有待执行任务可以运行");
1455
1508
  if (!this.store.isTaskSourceCurrent(taskId)) {
@@ -1472,28 +1525,28 @@ export class AiManager {
1472
1525
  const scope = task.scope;
1473
1526
  let result;
1474
1527
  if (taskType === "chapter-analysis") {
1475
- result = await this.runChapterAnalysis(workId, scope, modelId, taskId);
1528
+ result = await this.runChapterAnalysis(workId, scope, selectedModelId, taskId);
1476
1529
  }
1477
1530
  else if (taskType === "character-extraction" || taskType === "character-summary") {
1478
- result = await this.runCharacterExtraction(workId, scope, modelId, taskId);
1531
+ result = await this.runCharacterExtraction(workId, scope, selectedModelId, taskId);
1479
1532
  }
1480
1533
  else if (taskType === "character-identity-audit") {
1481
- result = await this.runCharacterIdentityAudit(workId, scope, modelId, taskId);
1534
+ result = await this.runCharacterIdentityAudit(workId, scope, selectedModelId, taskId);
1482
1535
  }
1483
1536
  else if (taskType === "timeline-analysis") {
1484
- result = await this.runTimelineAnalysis(workId, scope, modelId, taskId);
1537
+ result = await this.runTimelineAnalysis(workId, scope, selectedModelId, taskId);
1485
1538
  }
1486
1539
  else if (taskType === "relationship-analysis") {
1487
- result = await this.runRelationshipAnalysis(workId, scope, modelId, taskId);
1540
+ result = await this.runRelationshipAnalysis(workId, scope, selectedModelId, taskId);
1488
1541
  }
1489
1542
  else if (taskType === "worldview-analysis") {
1490
- result = await this.runWorldviewAnalysis(workId, scope, modelId, taskId);
1543
+ result = await this.runWorldviewAnalysis(workId, scope, selectedModelId, taskId);
1491
1544
  }
1492
1545
  else if (taskType === "setting-extraction") {
1493
- result = await this.runSettingExtraction(workId, scope, modelId, taskId);
1546
+ result = await this.runSettingExtraction(workId, scope, selectedModelId, taskId);
1494
1547
  }
1495
1548
  else if (taskType === "consistency-check") {
1496
- result = await this.runConsistencyCheck(workId, scope, modelId, taskId);
1549
+ result = await this.runConsistencyCheck(workId, scope, selectedModelId, taskId);
1497
1550
  }
1498
1551
  else {
1499
1552
  const generated = await this.generate({
@@ -1503,7 +1556,7 @@ export class AiManager {
1503
1556
  instruction: "请基于上下文完成分析,给出有原文依据的中文结论。",
1504
1557
  scope,
1505
1558
  signal: taskController.signal,
1506
- ...(modelId ? { modelId } : {})
1559
+ ...(selectedModelId ? { modelId: selectedModelId } : {})
1507
1560
  });
1508
1561
  result = { content: generated.content, callId: generated.callId };
1509
1562
  }
@@ -3297,8 +3350,651 @@ export class AiManager {
3297
3350
  }
3298
3351
  };
3299
3352
  }
3300
- relationshipSettingSources(workId, characters) {
3301
- const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
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) {
3302
3998
  const cleanStrings = (value) => {
3303
3999
  if (typeof value === "string")
3304
4000
  return collapseAiBlankLines(value);
@@ -3309,123 +4005,175 @@ export class AiManager {
3309
4005
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cleanStrings(item)]));
3310
4006
  };
3311
4007
  const serialize = (value) => JSON.stringify(cleanStrings(value), null, 2);
3312
- const source = (sourceType, sourceId, title, value) => ({
3313
- id: sourceType === "setting" ? String(sourceId) : `${sourceType}:${String(sourceId)}`,
4008
+ const source = (title, value, version) => ({
4009
+ id: sourceType === "setting" ? sourceId : `${sourceType}:${sourceId}`,
3314
4010
  title,
3315
4011
  sourceType,
3316
- content: serialize(value)
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] : [];
3317
4176
  });
3318
- const work = this.store.getWork(workId);
3319
- const settings = this.store.listSettings(workId).map((item) => source("setting", item.id, String(item.title), {
3320
- category: item.category,
3321
- content: item.content,
3322
- tags: item.tags,
3323
- status: item.status,
3324
- authorNote: item.authorNote
3325
- }));
3326
- const characterSources = this.store.listCharacters(workId, true).map((item) => source("character", item.id, `人物档案:${String(item.name)}`, {
3327
- name: item.name,
3328
- aliases: item.aliases,
3329
- code: item.code,
3330
- species: item.species,
3331
- race: item.race,
3332
- organizations: item.organizations,
3333
- attributes: item.attributes,
3334
- profile: item.profile,
3335
- currentState: item.currentState,
3336
- lockedFields: item.lockedFields
3337
- }));
3338
- const races = this.store.listRaces(workId).map((item) => source("race", item.id, `种族设定:${String(item.name)}`, {
3339
- name: item.name,
3340
- description: item.description,
3341
- lineage: item.lineage,
3342
- settings: item.settings,
3343
- effectiveSettings: item.effectiveSettings,
3344
- members: item.members
3345
- }));
3346
- const organizations = this.store.listOrganizations(workId).map((item) => source("organization", item.id, `组织设定:${String(item.name)}`, {
3347
- name: item.name,
3348
- description: item.description,
3349
- settings: item.settings,
3350
- members: item.members
3351
- }));
3352
- const tracks = this.store.listTimelineTracks(workId).map((item) => source("timeline-track", item.id, `时间轴:${String(item.name)}`, {
3353
- name: item.name,
3354
- description: item.description
3355
- }));
3356
- const timeline = this.store.listTimelineEvents(workId).map((item) => source("timeline-event", item.id, `时间线事件:${String(item.name)}`, {
3357
- name: item.name,
3358
- description: item.description,
3359
- eventType: item.eventType,
3360
- timeLabel: item.timeLabel,
3361
- participants: (Array.isArray(item.participantIds) ? item.participantIds : []).map((characterId) => ({
3362
- characterId,
3363
- name: characterNameById.get(String(characterId)) ?? "已删除角色"
3364
- })),
3365
- location: item.location,
3366
- causes: item.causes,
3367
- impactScope: item.impactScope,
3368
- evidence: item.evidence,
3369
- status: item.status
3370
- }));
3371
- const relationships = this.store.listRelationships(workId).map((item) => source("relationship", item.id, `人物关系:${characterNameById.get(String(item.fromCharacterId)) ?? "已删除角色"} / ${characterNameById.get(String(item.toCharacterId)) ?? "已删除角色"}`, {
3372
- fromCharacter: { id: item.fromCharacterId, name: characterNameById.get(String(item.fromCharacterId)) ?? "已删除角色" },
3373
- toCharacter: { id: item.toCharacterId, name: characterNameById.get(String(item.toCharacterId)) ?? "已删除角色" },
3374
- category: item.category,
3375
- subtype: item.subtype,
3376
- keywords: item.keywords,
3377
- directed: item.directed,
3378
- currentStatus: item.currentStatus,
3379
- timeRange: item.timeRange,
3380
- confidence: item.confidence,
3381
- evidence: item.evidence,
3382
- confirmationStatus: item.confirmationStatus,
3383
- locked: item.locked
3384
- }));
3385
- const outlines = this.store.listChapterOutlines(workId).map((item) => source("chapter-outline", item.chapterId, `章节大纲:${String(item.volumeTitle)} / ${String(item.chapterTitle)}`, {
3386
- chapterTitle: item.chapterTitle,
3387
- volumeTitle: item.volumeTitle,
3388
- goal: item.goal,
3389
- conflict: item.conflict,
3390
- turningPoint: item.turningPoint,
3391
- notes: item.notes,
3392
- status: item.status
3393
- }));
3394
- const foreshadows = this.store.listForeshadows(workId).map((item) => source("foreshadow", item.id, `伏笔:${String(item.title)}`, {
3395
- title: item.title,
3396
- description: item.description,
3397
- status: item.status,
3398
- importance: item.importance,
3399
- resolutionNote: item.resolutionNote,
3400
- occurrences: item.occurrences
3401
- }));
3402
- const reviews = this.store.listReviewItems(workId).map((item) => source("review", item.id, `审核项:${String(item.title)}`, {
3403
- itemType: item.itemType,
3404
- severity: item.severity,
3405
- title: item.title,
3406
- description: item.description,
3407
- evidence: item.evidence,
3408
- suggestion: item.suggestion,
3409
- status: item.status,
3410
- resolutionNote: item.resolutionNote
3411
- }));
3412
- return [source("work", work.id, `作品资料:${String(work.title)}`, {
3413
- title: work.title,
3414
- author: work.author,
3415
- description: work.description,
3416
- language: work.language
3417
- }), ...settings, ...characterSources, ...races, ...organizations, ...tracks, ...timeline, ...relationships, ...outlines, ...foreshadows, ...reviews];
3418
- }
3419
- relationshipSearchKeywords(characters, selectedCharacterIds) {
3420
- return [...new Set(characters
3421
- .filter((character) => selectedCharacterIds.has(String(character.id)))
3422
- .flatMap((character) => [String(character.name), ...(character.aliases ?? []).map(String)])
3423
- .map((keyword) => keyword.normalize("NFKC").trim().toLocaleLowerCase("zh-CN"))
3424
- .filter(Boolean))];
3425
- }
3426
- relationshipSourceContainsKeyword(source, keywords) {
3427
- const searchable = `${String(source.title ?? "")}\n${String(source.content ?? "")}`.normalize("NFKC").toLocaleLowerCase("zh-CN");
3428
- return keywords.some((keyword) => searchable.includes(keyword));
3429
4177
  }
3430
4178
  async runRelationshipAnalysis(workId, scope, modelId, taskId) {
3431
4179
  const characters = this.store.listCharacters(workId);
@@ -3443,20 +4191,18 @@ export class AiManager {
3443
4191
  .filter((character) => selectedCharacterIds.has(String(character.id)))
3444
4192
  .map((character) => `${String(character.id)} | ${String(character.name)}`)
3445
4193
  .join("\n");
3446
- const searchKeywords = targeted ? this.relationshipSearchKeywords(characters, selectedCharacterIds) : [];
3447
- const scopedChapters = settingsOnly ? [] : this.getScopeChapters(workId, scope);
3448
- const chapters = targeted
3449
- ? scopedChapters.filter((chapter) => this.relationshipSourceContainsKeyword(chapter, searchKeywords))
3450
- : scopedChapters;
3451
- const availableSettings = settingsOnly || scope.includeAllSettings === true
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
3452
4200
  ? this.relationshipSettingSources(workId, characters)
3453
4201
  : [];
3454
- const settings = targeted
3455
- ? availableSettings.filter((setting) => this.relationshipSourceContainsKeyword(setting, searchKeywords))
3456
- : availableSettings;
3457
- if (settingsOnly && availableSettings.length === 0)
4202
+ const settings = sourceSelection?.settings ?? availableSettings;
4203
+ if (!targeted && settingsOnly && availableSettings.length === 0)
3458
4204
  throw new AppError(409, "SETTINGS_REQUIRED", "人物关系分析范围内没有设定数据");
3459
- if (!settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
4205
+ if (!targeted && !settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
3460
4206
  throw new AppError(409, "RELATIONSHIP_SOURCES_REQUIRED", "人物关系分析范围内没有章节或设定数据");
3461
4207
  }
3462
4208
  const chunks = [
@@ -3478,7 +4224,8 @@ export class AiManager {
3478
4224
  targetedEvidenceCount: 0,
3479
4225
  aggregationBatchCount: 0,
3480
4226
  replacedRelationshipCount: 0,
3481
- callIds: []
4227
+ sourceSelection: sourceSelection?.summary,
4228
+ callIds: sourceSelection?.verificationCallIds ?? []
3482
4229
  };
3483
4230
  }
3484
4231
  const concurrency = this.configuredConcurrency(workId, "relationship-analysis", modelId);
@@ -3489,7 +4236,7 @@ export class AiManager {
3489
4236
  const rawCandidates = [];
3490
4237
  const chapterEvidenceCandidates = [];
3491
4238
  const settingCandidates = [];
3492
- const callIds = [];
4239
+ const callIds = [...(sourceSelection?.verificationCallIds ?? [])];
3493
4240
  const settingsInstruction = [
3494
4241
  "你是小说人物关系设定抽取器,不是续写者。只根据本批系统设定数据抽取角色规范表中人物之间被明确写出的长期关系。",
3495
4242
  ...(targeted ? ["被分析角色:", targetedRoster, "只输出至少一端属于被分析角色的关系。"] : []),
@@ -4079,6 +4826,52 @@ export class AiManager {
4079
4826
  if (taskId && settingsOnly)
4080
4827
  this.store.refreshTaskSourceVersions(taskId);
4081
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
+ }
4082
4875
  const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
4083
4876
  const relationshipResults = [...relationshipOutcomes.values()].map(({ action, relationship }) => {
4084
4877
  const evidence = Array.isArray(relationship.evidence)
@@ -4159,6 +4952,7 @@ export class AiManager {
4159
4952
  targetedEvidenceCount,
4160
4953
  aggregationBatchCount,
4161
4954
  replacedRelationshipCount,
4955
+ ...(sourceSelection ? { sourceSelection: sourceSelection.summary } : {}),
4162
4956
  callIds
4163
4957
  };
4164
4958
  }
@@ -4645,6 +5439,17 @@ export class AiManager {
4645
5439
  this.assertAvailable(provider, model);
4646
5440
  return { model, provider };
4647
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
+ }
4648
5453
  configuredConcurrency(workId, taskType, modelId) {
4649
5454
  const { provider } = this.resolveModel(workId, taskType, modelId);
4650
5455
  return Math.round(clamp(numberValue(provider, "concurrency_limit") || 10, 1, 100));