@musnows/scriverse 0.5.0 → 0.5.2

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
@@ -33,6 +33,45 @@ function thinkingParameters(provider, model) {
33
33
  return { thinking: { type: boolValue(model, "thinking_enabled") ? "enabled" : "disabled" } };
34
34
  }
35
35
  const AGENT_TOOL_IDS = ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"];
36
+ function traceRecord(value) {
37
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
38
+ }
39
+ function taskTraceSourceRefs(initialMessages, rounds) {
40
+ const refs = [];
41
+ const seen = new Set();
42
+ const add = (type, title) => {
43
+ const normalizedTitle = title.trim();
44
+ if (!normalizedTitle)
45
+ return;
46
+ const key = `${type}|${normalizedTitle}`;
47
+ if (seen.has(key))
48
+ return;
49
+ seen.add(key);
50
+ refs.push({ type, title: normalizedTitle });
51
+ };
52
+ const roundMessages = rounds.flatMap((value) => {
53
+ const request = traceRecord(traceRecord(value).request);
54
+ return Array.isArray(request.messages) ? request.messages : [];
55
+ });
56
+ for (const message of [...initialMessages, ...roundMessages]) {
57
+ const content = String(traceRecord(message).content ?? "");
58
+ for (const match of content.matchAll(/<CHAPTER\b[^>]*\btitle="([^"]+)"[^>]*>/gu))
59
+ add("chapter", match[1] ?? "");
60
+ for (const match of content.matchAll(/<SETTING\b[^>]*\btitle="([^"]+)"[^>]*>/gu))
61
+ add("setting", match[1] ?? "");
62
+ for (const match of content.matchAll(/\[(?:# )?([^\]\n]+?)\s*\|\s*版本\s+\d+\]/gu)) {
63
+ const title = (match[1] ?? "").split(/\s+\/\s+/u).at(-1) ?? "";
64
+ add("chapter", title);
65
+ }
66
+ for (const match of content.matchAll(/(?:当前章节|所在章节):([^\n|]+?)\s*\|\s*版本\s+\d+/gu))
67
+ add("chapter", match[1] ?? "");
68
+ for (const match of content.matchAll(/"chapterTitle"\s*:\s*"([^"]+)"/gu))
69
+ add("chapter", match[1] ?? "");
70
+ for (const match of content.matchAll(/"chapterId"\s*:\s*"[^"]+"[\s\S]{0,240}?"title"\s*:\s*"([^"]+)"/gu))
71
+ add("chapter", match[1] ?? "");
72
+ }
73
+ return refs;
74
+ }
36
75
  function redactProviderSecret(value, apiKey) {
37
76
  if (!apiKey)
38
77
  return value;
@@ -162,6 +201,11 @@ export function estimateAiTokens(value) {
162
201
  }
163
202
  return Math.max(1, Math.ceil(wideCharacters * 1.1 + narrowCharacters / 4));
164
203
  }
204
+ export function collapseAiBlankLines(value) {
205
+ return value
206
+ .replace(/\r\n?/gu, "\n")
207
+ .replace(/\n[\t ]*\n(?:[\t ]*\n)+/gu, "\n\n");
208
+ }
165
209
  function contextSearchTerms(value) {
166
210
  const normalized = value.normalize("NFKC").toLocaleLowerCase("zh-CN");
167
211
  const terms = new Set();
@@ -513,17 +557,20 @@ export class ContextBuilder {
513
557
  }
514
558
  buildPlan(workId, scope, maximumTokens = 60_000, bookSummaryMaximumTokens, query = "") {
515
559
  const work = this.store.getWork(workId);
516
- const includeAutomaticContext = scope.type !== "none";
560
+ const includeAutomaticContext = scope.type !== "none" && scope.suppressAutomaticContext !== true;
561
+ const settingsOnly = scope.type === "settings";
517
562
  const constraints = includeAutomaticContext
518
563
  ? [`作品:${String(work.title)}\n作者:${String(work.author) || "未填写"}`]
519
564
  : [];
520
565
  const contentSections = [];
521
566
  const availableSettings = this.store.listSettings(workId);
522
- const contextualSettings = scope.includeAllSettings ? availableSettings : availableSettings.filter((item) => item.locked);
567
+ const contextualSettings = !includeAutomaticContext || settingsOnly
568
+ ? []
569
+ : scope.includeAllSettings ? availableSettings : availableSettings.filter((item) => item.locked);
523
570
  const allCharacters = this.store.listCharacters(workId);
524
571
  const lockedCharacters = allCharacters.filter((item) => Array.isArray(item.lockedFields) && item.lockedFields.length > 0);
525
572
  const organizations = this.store.listOrganizations(workId);
526
- const relationshipConstraints = scope.excludeRelationshipConstraints
573
+ const relationshipConstraints = !includeAutomaticContext || settingsOnly || scope.excludeRelationshipConstraints
527
574
  ? []
528
575
  : selectRelationshipConstraints(this.store, workId, scope.characterIds ?? []);
529
576
  if (includeAutomaticContext && contextualSettings.length > 0) {
@@ -531,7 +578,7 @@ export class ContextBuilder {
531
578
  .map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`)
532
579
  .join("\n")}`);
533
580
  }
534
- if (includeAutomaticContext && lockedCharacters.length > 0) {
581
+ if (includeAutomaticContext && !settingsOnly && lockedCharacters.length > 0) {
535
582
  constraints.push(`作者锁定角色属性(硬约束):\n${lockedCharacters
536
583
  .map((item) => {
537
584
  const locked = item.lockedFields;
@@ -546,7 +593,7 @@ export class ContextBuilder {
546
593
  })
547
594
  .join("\n")}`);
548
595
  }
549
- if (includeAutomaticContext && organizations.length > 0) {
596
+ if (includeAutomaticContext && !settingsOnly && organizations.length > 0) {
550
597
  constraints.push(`世界内组织:\n${organizations.map((item) => {
551
598
  const settings = Array.isArray(item.settings) ? item.settings.map(String).filter(Boolean) : [];
552
599
  const members = Array.isArray(item.members)
@@ -603,6 +650,9 @@ export class ContextBuilder {
603
650
  }
604
651
  }
605
652
  }
653
+ else if (scope.type === "settings" && scope.selection) {
654
+ contentSections.push(`待分析设定:\n${scope.selection}`);
655
+ }
606
656
  if (scope.includeBookSummary || scope.type === "book" || scope.type === "volume") {
607
657
  this.appendBookSummary(contentSections, workId, bookSummaryMaximumTokens ?? Math.max(160, Math.floor(maximumTokens * 0.35)), query, scope.type === "volume" ? scope.volumeId : undefined);
608
658
  }
@@ -631,7 +681,9 @@ export class ContextBuilder {
631
681
  if (setting.workId !== workId)
632
682
  throw new AppError(400, "SETTING_WORK_MISMATCH", "设定不属于当前作品");
633
683
  }
634
- constraints.push(`选定设定:\n${settings.map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`).join("\n")}`);
684
+ constraints.push(settingsOnly
685
+ ? `设定集条目:\n${settings.map((item) => `<SETTING id="${String(item.id)}" title="${String(item.title).replaceAll('"', "'")}">\n${String(item.content)}\n</SETTING>`).join("\n\n")}`
686
+ : `选定设定:\n${settings.map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`).join("\n")}`);
635
687
  }
636
688
  if (scope.chapterIds?.length) {
637
689
  const chapterIds = [...new Set(scope.chapterIds)]
@@ -658,7 +710,7 @@ export class ContextBuilder {
658
710
  });
659
711
  }
660
712
  const sections = contentSections.map((text, order) => {
661
- const required = /^(?:当前选中文本|当前章节|所在章节|作者主动引用的章节)/u.test(text);
713
+ const required = /^(?:当前选中文本|当前章节|所在章节|作者主动引用的章节|待分析设定)/u.test(text);
662
714
  const summary = /章节概要(/u.test(text);
663
715
  return {
664
716
  id: `context-${order}`,
@@ -1312,9 +1364,14 @@ export class AiManager {
1312
1364
  })), pagination);
1313
1365
  }
1314
1366
  getTaskTrace(taskId) {
1315
- this.store.getTask(taskId);
1316
- const rows = this.store.db.all(`SELECT call.*, trace.initial_messages_json, trace.rounds_json, trace.created_at AS trace_created_at,
1317
- trace.updated_at AS trace_updated_at, provider.name AS provider_name,
1367
+ this.store.getTaskWorkId(taskId);
1368
+ const rows = this.store.db.all(`SELECT call.id, call.task_type, call.provider_id, call.model_id, call.status, call.failure,
1369
+ call.input_chars, call.output_chars, call.created_at, call.completed_at, trace.call_id AS trace_call_id,
1370
+ trace.source_refs_json,
1371
+ CASE WHEN trace.call_id IS NULL THEN 0 ELSE json_array_length(trace.initial_messages_json) END AS initial_message_count,
1372
+ CASE WHEN trace.call_id IS NULL THEN 0 ELSE json_array_length(trace.rounds_json) END AS round_count,
1373
+ CASE WHEN trace.call_id IS NULL THEN 0 ELSE length(trace.initial_messages_json) + length(trace.rounds_json) END AS trace_chars,
1374
+ trace.created_at AS trace_created_at, trace.updated_at AS trace_updated_at, provider.name AS provider_name,
1318
1375
  model.display_name AS model_display_name, model.model_id AS external_model_id
1319
1376
  FROM ai_calls call
1320
1377
  LEFT JOIN ai_call_traces trace ON trace.call_id = call.id
@@ -1323,7 +1380,11 @@ export class AiManager {
1323
1380
  WHERE call.task_id = ?
1324
1381
  ORDER BY call.created_at ASC, call.id ASC`, taskId);
1325
1382
  const calls = rows.map((row) => {
1326
- const hasTrace = row.initial_messages_json !== null && row.initial_messages_json !== undefined;
1383
+ const hasTrace = row.trace_call_id !== null && row.trace_call_id !== undefined;
1384
+ const failure = row.failure === null ? null : stringValue(row, "failure");
1385
+ const sourceRefs = hasTrace
1386
+ ? json(stringValue(row, "source_refs_json"), [])
1387
+ : [];
1327
1388
  return {
1328
1389
  id: stringValue(row, "id"),
1329
1390
  taskType: stringValue(row, "task_type"),
@@ -1338,17 +1399,19 @@ export class AiManager {
1338
1399
  modelId: row.external_model_id === null ? null : stringValue(row, "external_model_id"),
1339
1400
  deleted: row.model_display_name === null
1340
1401
  },
1341
- contextScope: json(stringValue(row, "context_scope_json"), {}),
1342
- parameters: json(stringValue(row, "parameters_json"), {}),
1343
1402
  status: stringValue(row, "status"),
1344
- failure: row.failure === null ? null : stringValue(row, "failure"),
1403
+ failure: failure === null ? null : failure.slice(0, 1_000),
1404
+ failureTruncated: failure !== null && failure.length > 1_000,
1345
1405
  inputChars: numberValue(row, "input_chars"),
1346
1406
  outputChars: numberValue(row, "output_chars"),
1347
1407
  createdAt: stringValue(row, "created_at"),
1348
1408
  completedAt: row.completed_at === null ? null : stringValue(row, "completed_at"),
1409
+ sourceRefs,
1349
1410
  trace: hasTrace ? {
1350
- initialMessages: json(stringValue(row, "initial_messages_json"), []),
1351
- rounds: json(stringValue(row, "rounds_json"), []),
1411
+ available: true,
1412
+ initialMessageCount: numberValue(row, "initial_message_count"),
1413
+ roundCount: numberValue(row, "round_count"),
1414
+ serializedChars: numberValue(row, "trace_chars"),
1352
1415
  createdAt: stringValue(row, "trace_created_at"),
1353
1416
  updatedAt: stringValue(row, "trace_updated_at")
1354
1417
  } : null
@@ -1360,6 +1423,27 @@ export class AiManager {
1360
1423
  calls
1361
1424
  };
1362
1425
  }
1426
+ getTaskTraceCall(taskId, callId) {
1427
+ this.store.getTaskWorkId(taskId);
1428
+ const row = this.store.db.get(`SELECT trace.initial_messages_json, trace.rounds_json, trace.created_at, trace.updated_at
1429
+ FROM ai_calls call JOIN ai_call_traces trace ON trace.call_id = call.id AND trace.task_id = call.task_id
1430
+ WHERE call.id = ? AND call.task_id = ?`, callId, taskId);
1431
+ if (!row)
1432
+ throw notFound("AI 调用追踪");
1433
+ const initialMessages = json(stringValue(row, "initial_messages_json"), []);
1434
+ const rounds = json(stringValue(row, "rounds_json"), []);
1435
+ return {
1436
+ taskId,
1437
+ callId,
1438
+ mode: "full",
1439
+ trace: {
1440
+ initialMessages,
1441
+ rounds,
1442
+ createdAt: stringValue(row, "created_at"),
1443
+ updatedAt: stringValue(row, "updated_at")
1444
+ }
1445
+ };
1446
+ }
1363
1447
  async runTask(taskId, modelId) {
1364
1448
  const task = this.store.getTask(taskId);
1365
1449
  const workId = String(task.workId);
@@ -1650,7 +1734,7 @@ export class AiManager {
1650
1734
  return this.contextBuilder.buildPlan(input.workId, input.scope, workContextBudgetTokens, bookSummaryMaximumTokens, input.instruction);
1651
1735
  }
1652
1736
  buildContext(input, model) {
1653
- return this.buildContextPlan(input, model).context;
1737
+ return collapseAiBlankLines(this.buildContextPlan(input, model).context);
1654
1738
  }
1655
1739
  enabledAgentToolIds(workId, taskType, requestedToolIds) {
1656
1740
  if (taskType !== "chat" && requestedToolIds === undefined)
@@ -1758,7 +1842,7 @@ export class AiManager {
1758
1842
  const chapter = this.store.getChapter(chapterId);
1759
1843
  if (chapter.workId !== workId)
1760
1844
  return { chapterId, error: { code: "CHAPTER_WORK_MISMATCH", message: "The requested chapter belongs to a different work." } };
1761
- const content = String(chapter.content);
1845
+ const content = collapseAiBlankLines(String(chapter.content));
1762
1846
  const excerpt = content.slice(0, Math.max(0, remainingChars));
1763
1847
  remainingChars -= excerpt.length;
1764
1848
  return { chapterId, title: chapter.title, versionNo: chapter.versionNo, ...(include !== "content" ? { summary: summaries.get(chapterId) ?? "" } : {}), ...(include !== "summary" ? { content: excerpt, contentTruncated: excerpt.length < content.length } : {}) };
@@ -1820,7 +1904,7 @@ export class AiManager {
1820
1904
  const section = this.store.getCharacterProfileSection(sectionId);
1821
1905
  if (section.workId !== workId)
1822
1906
  return { sectionId, error: { code: "CHARACTER_SECTION_WORK_MISMATCH", message: "The requested character section belongs to a different work." } };
1823
- const content = String(section.contentMarkdown);
1907
+ const content = collapseAiBlankLines(String(section.contentMarkdown));
1824
1908
  const excerpt = content.slice(0, Math.max(0, remainingChars));
1825
1909
  remainingChars -= excerpt.length;
1826
1910
  const character = this.store.getCharacter(String(section.characterId));
@@ -1881,14 +1965,14 @@ export class AiManager {
1881
1965
  this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
1882
1966
  status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskId ?? null, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(parameters), context.length + input.instruction.length, timestamp, currentRequestActor()?.userId ?? null);
1883
1967
  if (input.taskId) {
1884
- this.store.db.run(`INSERT INTO ai_call_traces (call_id, task_id, initial_messages_json, rounds_json, created_at, updated_at)
1885
- VALUES (?, ?, ?, '[]', ?, ?)`, callId, input.taskId, JSON.stringify(messages), timestamp, timestamp);
1968
+ this.store.db.run(`INSERT INTO ai_call_traces (call_id, task_id, initial_messages_json, rounds_json, source_refs_json, created_at, updated_at)
1969
+ VALUES (?, ?, ?, '[]', ?, ?, ?)`, callId, input.taskId, JSON.stringify(messages), JSON.stringify(taskTraceSourceRefs(messages, [])), timestamp, timestamp);
1886
1970
  }
1887
1971
  });
1888
1972
  const saveTrace = () => {
1889
1973
  if (!input.taskId)
1890
1974
  return;
1891
- this.store.db.run("UPDATE ai_call_traces SET rounds_json = ?, updated_at = ? WHERE call_id = ?", JSON.stringify(traceRounds), now(), callId);
1975
+ this.store.db.run("UPDATE ai_call_traces SET rounds_json = ?, source_refs_json = ?, updated_at = ? WHERE call_id = ?", JSON.stringify(traceRounds), JSON.stringify(taskTraceSourceRefs(messages, traceRounds)), now(), callId);
1892
1976
  };
1893
1977
  const callStartedAt = process.hrtime.bigint();
1894
1978
  logger.info("ai.call.started", {
@@ -3213,10 +3297,141 @@ export class AiManager {
3213
3297
  }
3214
3298
  };
3215
3299
  }
3300
+ relationshipSettingSources(workId, characters) {
3301
+ const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
3302
+ const cleanStrings = (value) => {
3303
+ if (typeof value === "string")
3304
+ return collapseAiBlankLines(value);
3305
+ if (Array.isArray(value))
3306
+ return value.map(cleanStrings);
3307
+ if (!value || typeof value !== "object")
3308
+ return value;
3309
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cleanStrings(item)]));
3310
+ };
3311
+ 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)}`,
3314
+ title,
3315
+ sourceType,
3316
+ content: serialize(value)
3317
+ });
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
+ }
3216
3430
  async runRelationshipAnalysis(workId, scope, modelId, taskId) {
3217
3431
  const characters = this.store.listCharacters(workId);
3218
3432
  if (characters.length < 2)
3219
3433
  throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
3434
+ const settingsOnly = scope.type === "settings";
3220
3435
  const selectedCharacterIds = new Set(scope.characterIds ?? []);
3221
3436
  for (const characterId of selectedCharacterIds) {
3222
3437
  const character = characters.find((item) => item.id === characterId);
@@ -3228,33 +3443,87 @@ export class AiManager {
3228
3443
  .filter((character) => selectedCharacterIds.has(String(character.id)))
3229
3444
  .map((character) => `${String(character.id)} | ${String(character.name)}`)
3230
3445
  .join("\n");
3231
- const chapters = this.getScopeChapters(workId, scope);
3232
- if (chapters.length === 0)
3233
- throw new AppError(409, "CHAPTERS_REQUIRED", "人物关系分析范围内没有章节");
3234
- const chunks = this.buildChapterChunks(chapters, 12_000);
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
3452
+ ? this.relationshipSettingSources(workId, characters)
3453
+ : [];
3454
+ const settings = targeted
3455
+ ? availableSettings.filter((setting) => this.relationshipSourceContainsKeyword(setting, searchKeywords))
3456
+ : availableSettings;
3457
+ if (settingsOnly && availableSettings.length === 0)
3458
+ throw new AppError(409, "SETTINGS_REQUIRED", "人物关系分析范围内没有设定数据");
3459
+ if (!settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
3460
+ throw new AppError(409, "RELATIONSHIP_SOURCES_REQUIRED", "人物关系分析范围内没有章节或设定数据");
3461
+ }
3462
+ const chunks = [
3463
+ ...this.buildChapterChunks(chapters, 12_000).map((chunk) => ({ ...chunk, sourceKind: "chapter" })),
3464
+ ...this.buildSettingChunks(settings, 12_000).map((chunk) => ({ ...chunk, sourceKind: "setting" }))
3465
+ ];
3466
+ if (targeted && chunks.length === 0) {
3467
+ return {
3468
+ relationshipIds: [],
3469
+ candidateCount: 0,
3470
+ rawCandidateCount: 0,
3471
+ skipped: [{ index: -1, reason: "没有章节或设定数据命中被分析角色的名称或别名" }],
3472
+ batchCount: 0,
3473
+ coveredChapterCount: 0,
3474
+ coveredSettingCount: 0,
3475
+ fallbackSegmentCount: 0,
3476
+ policyOmittedSegmentCount: 0,
3477
+ targetedCharacterIds: [...selectedCharacterIds],
3478
+ targetedEvidenceCount: 0,
3479
+ aggregationBatchCount: 0,
3480
+ replacedRelationshipCount: 0,
3481
+ callIds: []
3482
+ };
3483
+ }
3235
3484
  const concurrency = this.configuredConcurrency(workId, "relationship-analysis", modelId);
3236
3485
  const roster = characters.map((character) => {
3237
3486
  const aliases = character.aliases.filter((alias) => this.isSafeGlobalAlias(alias));
3238
3487
  return `${String(character.id)} | ${String(character.name)}${aliases.length ? ` | 别名:${aliases.join("、")}` : ""}`;
3239
3488
  }).join("\n");
3240
3489
  const rawCandidates = [];
3490
+ const chapterEvidenceCandidates = [];
3491
+ const settingCandidates = [];
3241
3492
  const callIds = [];
3242
- const extractChunk = async (text, maxAttempts = 3) => {
3493
+ const settingsInstruction = [
3494
+ "你是小说人物关系设定抽取器,不是续写者。只根据本批系统设定数据抽取角色规范表中人物之间被明确写出的长期关系。",
3495
+ ...(targeted ? ["被分析角色:", targetedRoster, "只输出至少一端属于被分析角色的关系。"] : []),
3496
+ "完整角色规范表:",
3497
+ roster,
3498
+ "硬规则:",
3499
+ "1. 本批 SETTING 条目是本次唯一事实来源;它可能来自作品设定、人物档案、种族、组织、时间线、已有关系、大纲、伏笔或审核项,不得引用未提供的数据或常识补全关系。",
3500
+ "2. 人名、别名、昵称和拼写变体必须归一到唯一 characterId,禁止创造角色或把相似名字强行合并。",
3501
+ "3. 只抽取条目明确陈述的长期亲属、社会、情感或冲突关系;同场出现、同属阵营、相似背景和推测性措辞不能生成关系。",
3502
+ "4. 父母→子女、君王→臣属、导师→学生、施害者→受害者、倾慕者→被倾慕者使用 directed=true;伴侣、朋友、手足、盟友、互为宿敌使用 directed=false。",
3503
+ "5. category 只能是 family、social、emotional、conflict、uncertain;confidence 低于 0.6 不输出。",
3504
+ "6. subtype 使用简短稳定中文词;同一人物对、同一 category、同一 subtype 只输出一次,不得输出反向重复边。",
3505
+ "7. keywords 提供 2 至 8 个描述双方互动、权力结构、情感阶段或剧情张力的中文关键词。",
3506
+ "8. 每条 evidence 必须提供 settingId、settingTitle、quote、supports;quote 必须是对应设定条目中的连续原文短句且不超过 80 字。",
3507
+ "9. evidence 的 quote 和 supports 必须能共同识别关系双方及关系类型,不能只凭一方名字或模糊代词建立关系。",
3508
+ "10. 输出 JSON 数组。字段:fromCharacterId、toCharacterId、category、subtype、keywords、directed、currentStatus、timeRange、confidence、evidence。没有明确关系时输出 []。"
3509
+ ].join("\n");
3510
+ const extractChunk = async (chunk, maxAttempts = 3) => {
3243
3511
  const generated = await this.generateTaggedJson({
3244
3512
  workId,
3245
3513
  taskId,
3246
3514
  taskType: "relationship-analysis",
3247
3515
  signal: this.taskSignal(taskId),
3248
3516
  maxAttempts,
3249
- scope: {
3250
- type: "selection",
3251
- selection: text,
3252
- includeAllSettings: scope.includeAllSettings,
3253
- ...(targeted ? { characterIds: [...selectedCharacterIds], excludeRelationshipConstraints: scope.replaceExistingRelationships === true } : {})
3254
- },
3517
+ scope: chunk.sourceKind === "setting"
3518
+ ? { type: "settings", selection: chunk.text }
3519
+ : {
3520
+ type: "selection",
3521
+ selection: chunk.text,
3522
+ ...(targeted ? { suppressAutomaticContext: true } : {})
3523
+ },
3255
3524
  ...(modelId ? { modelId } : {}),
3256
3525
  parameters: { temperature: 0.1 },
3257
- instruction: targeted ? [
3526
+ instruction: chunk.sourceKind === "setting" ? settingsInstruction : targeted ? [
3258
3527
  "你是定向人物关系证据收集器。本阶段只建立跨章节证据账本,不下最终关系结论。",
3259
3528
  "被分析角色:",
3260
3529
  targetedRoster,
@@ -3299,9 +3568,11 @@ export class AiManager {
3299
3568
  "24. 共同执行一次任务、同属一个组织、在同一集体场景中被感谢或落泪、替第三人转发消息,都不能单独证明同事、朋友或盟友。此类关系必须有原文明示身份,或至少两个不同章节的持续互动证据。"
3300
3569
  ].join("\n"),
3301
3570
  extraSystemPrompt: [
3302
- targeted
3303
- ? "你正在为指定角色收集可审计的跨章节关系线索。不得在证据收集阶段把单次互动直接判定为长期关系。"
3304
- : "关系候选必须可审计。严禁把梦境伴侣、醉后梦话、单次约定、同章共现、礼称、同族归属、救援照护或类比提及写成现实长期关系。逐句校验说话人和关系方向。",
3571
+ chunk.sourceKind === "setting"
3572
+ ? "本次只允许使用提供的系统设定条目。每条结论都必须能回溯到对应 settingId 的原文引文。"
3573
+ : targeted
3574
+ ? "你正在为指定角色收集可审计的跨章节关系线索。不得在证据收集阶段把单次互动直接判定为长期关系。"
3575
+ : "关系候选必须可审计。严禁把梦境伴侣、醉后梦话、单次约定、同章共现、礼称、同族归属、救援照护或类比提及写成现实长期关系。逐句校验说话人和关系方向。",
3305
3576
  scope.additionalPrompt?.trim() ? `作者追加的关系分析提示:\n${scope.additionalPrompt.trim()}` : ""
3306
3577
  ].filter(Boolean).join("\n\n")
3307
3578
  });
@@ -3315,26 +3586,32 @@ export class AiManager {
3315
3586
  };
3316
3587
  const chunkResults = await this.processChunks(chunks, concurrency, async (chunk) => {
3317
3588
  if (taskId && this.store.getTask(taskId).status !== "running") {
3318
- return { candidates: [], callIds: [], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
3589
+ return { sourceKind: chunk.sourceKind, candidates: [], callIds: [], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
3319
3590
  }
3320
3591
  try {
3321
- const extracted = await extractChunk(chunk.text, 1);
3322
- return { candidates: extracted.candidates, callIds: [extracted.callId], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
3592
+ const extracted = await extractChunk(chunk, 1);
3593
+ return { sourceKind: chunk.sourceKind, candidates: extracted.candidates, callIds: [extracted.callId], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
3323
3594
  }
3324
3595
  catch {
3596
+ if (chunk.sourceKind === "setting")
3597
+ throw new AppError(502, "AI_SETTINGS_BATCH_FAILED", "设定数据人物关系分析批次失败");
3325
3598
  const segments = this.splitMarkedChapters(chunk.text);
3326
- return this.runChapterSegmentFallback(segments, taskId, extractChunk, undefined, concurrency);
3599
+ const fallback = await this.runChapterSegmentFallback(segments, taskId, async (text, maxAttempts) => extractChunk({ sourceKind: "chapter", text }, maxAttempts), undefined, concurrency);
3600
+ return { sourceKind: chunk.sourceKind, ...fallback };
3327
3601
  }
3328
3602
  }, (completed) => {
3329
3603
  if (taskId && this.store.getTask(taskId).status === "running") {
3330
- const maximumProgress = targeted ? 72 : 92;
3604
+ const maximumProgress = targeted && !settingsOnly ? 72 : 92;
3331
3605
  this.store.updateTask(taskId, { status: "running", progress: Math.min(maximumProgress, 5 + Math.round(completed / chunks.length * (maximumProgress - 5))) });
3332
3606
  }
3333
3607
  });
3334
3608
  let fallbackSegmentCount = 0;
3335
3609
  let policyOmittedSegmentCount = 0;
3336
3610
  for (const result of chunkResults) {
3337
- rawCandidates.push(...result.candidates);
3611
+ if (result.sourceKind === "setting")
3612
+ settingCandidates.push(...result.candidates);
3613
+ else
3614
+ chapterEvidenceCandidates.push(...result.candidates);
3338
3615
  callIds.push(...result.callIds);
3339
3616
  fallbackSegmentCount += result.fallbackSegmentCount;
3340
3617
  policyOmittedSegmentCount += result.policyOmittedSegmentCount;
@@ -3349,11 +3626,13 @@ export class AiManager {
3349
3626
  batchCount: chunks.length
3350
3627
  });
3351
3628
  }
3352
- const targetedEvidenceCount = targeted ? rawCandidates.length : 0;
3629
+ if (!targeted)
3630
+ rawCandidates.push(...chapterEvidenceCandidates, ...settingCandidates);
3631
+ const targetedEvidenceCount = targeted ? chapterEvidenceCandidates.length + settingCandidates.length : 0;
3353
3632
  let aggregationBatchCount = 0;
3354
- if (targeted && rawCandidates.length > 0) {
3633
+ if (targeted && !settingsOnly && chapterEvidenceCandidates.length > 0) {
3355
3634
  const evidenceGroups = new Map();
3356
- for (const evidence of rawCandidates) {
3635
+ for (const evidence of chapterEvidenceCandidates) {
3357
3636
  const target = String(evidence.targetCharacterId ?? "");
3358
3637
  const related = String(evidence.relatedCharacterId ?? evidence.relatedReference ?? "unknown");
3359
3638
  const key = `${target}|${related}`;
@@ -3386,9 +3665,7 @@ export class AiManager {
3386
3665
  maxAttempts: 2,
3387
3666
  scope: {
3388
3667
  type: "entities",
3389
- includeAllSettings: scope.includeAllSettings,
3390
- characterIds: [...selectedCharacterIds],
3391
- excludeRelationshipConstraints: scope.replaceExistingRelationships === true
3668
+ suppressAutomaticContext: true
3392
3669
  },
3393
3670
  ...(modelId ? { modelId } : {}),
3394
3671
  parameters: { temperature: 0.1 },
@@ -3428,10 +3705,14 @@ export class AiManager {
3428
3705
  this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 72 + Math.round(completed / evidenceBatches.length * 20)) });
3429
3706
  }
3430
3707
  });
3431
- rawCandidates.splice(0, rawCandidates.length, ...aggregationResults.flatMap((result) => result.candidates));
3708
+ rawCandidates.push(...aggregationResults.flatMap((result) => result.candidates));
3432
3709
  callIds.push(...aggregationResults.map((result) => result.callId));
3433
3710
  }
3711
+ if (targeted)
3712
+ rawCandidates.push(...settingCandidates);
3434
3713
  const chapterById = new Map(chapters.map((chapter) => [String(chapter.id), chapter]));
3714
+ const settingById = new Map(settings.map((setting) => [String(setting.id), setting]));
3715
+ const relationshipEvidenceKey = (item) => `${String(item.settingId ?? item.chapterId)}|${String(item.quote)}`;
3435
3716
  const categories = new Set(["family", "social", "emotional", "conflict", "uncertain"]);
3436
3717
  const merged = new Map();
3437
3718
  const skipped = [];
@@ -3480,21 +3761,36 @@ export class AiManager {
3480
3761
  [fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
3481
3762
  const evidence = (Array.isArray(candidate.evidence) ? candidate.evidence : [])
3482
3763
  .filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
3483
- .filter((item) => {
3484
- if (typeof item.chapterId !== "string" || typeof item.quote !== "string" || item.quote.trim().length > 80)
3485
- return false;
3764
+ .flatMap((item) => {
3765
+ if (typeof item.quote !== "string" || item.quote.trim().length > 80)
3766
+ return [];
3767
+ if (typeof item.settingId === "string") {
3768
+ const setting = settingById.get(item.settingId);
3769
+ if (!setting || !this.quoteExists(String(setting.content), item.quote))
3770
+ return [];
3771
+ return [{
3772
+ settingId: item.settingId,
3773
+ settingTitle: String(setting.title),
3774
+ quote: item.quote.trim(),
3775
+ contextType: "setting",
3776
+ supports: typeof item.supports === "string" ? item.supports : ""
3777
+ }];
3778
+ }
3779
+ if (typeof item.chapterId !== "string")
3780
+ return [];
3486
3781
  const chapter = chapterById.get(item.chapterId);
3487
- return Boolean(chapter && this.quoteExists(String(chapter.content), item.quote));
3488
- })
3489
- .map((item) => ({
3490
- chapterId: item.chapterId,
3491
- chapterTitle: String(chapterById.get(String(item.chapterId))?.title ?? ""),
3492
- quote: String(item.quote).trim(),
3493
- contextType: typeof item.contextType === "string" ? item.contextType : "current",
3494
- supports: typeof item.supports === "string" ? item.supports : ""
3495
- }));
3782
+ if (!chapter || !this.quoteExists(String(chapter.content), item.quote))
3783
+ return [];
3784
+ return [{
3785
+ chapterId: item.chapterId,
3786
+ chapterTitle: String(chapter.title),
3787
+ quote: item.quote.trim(),
3788
+ contextType: typeof item.contextType === "string" ? item.contextType : "current",
3789
+ supports: typeof item.supports === "string" ? item.supports : ""
3790
+ }];
3791
+ });
3496
3792
  if (evidence.length === 0) {
3497
- skipped.push({ index, reason: "证据引文未在对应章节原文命中" });
3793
+ skipped.push({ index, reason: "证据引文未在对应章节或设定条目命中" });
3498
3794
  return;
3499
3795
  }
3500
3796
  const evidenceText = evidence.map((item) => String(item.quote)).join("\n");
@@ -3514,9 +3810,9 @@ export class AiManager {
3514
3810
  }
3515
3811
  }
3516
3812
  if (category === "conflict" && subtype === "宿敌") {
3517
- const evidenceChapters = new Set(evidence.map((item) => String(item.chapterId)));
3813
+ const evidenceSources = new Set(evidence.map((item) => String(item.settingId ?? item.chapterId)));
3518
3814
  const explicitlyLongRunning = /宿敌|世仇|死敌|多年|长期|世代|一直.{0,24}(?:敌|威胁|对抗|杀手)|远古.{0,16}(?:战|敌)|多次.{0,16}(?:交战|对抗|冲突)/u.test(evidenceText);
3519
- if (evidenceChapters.size < 2 && !explicitlyLongRunning)
3815
+ if (evidenceSources.size < 2 && !explicitlyLongRunning)
3520
3816
  subtype = "战时敌对";
3521
3817
  }
3522
3818
  const key = [fromCharacterId, toCharacterId, category, this.normalizeReference(subtype), directed ? "1" : "0"].join("|");
@@ -3525,9 +3821,9 @@ export class AiManager {
3525
3821
  current.confidence = Math.max(current.confidence, confidence);
3526
3822
  current.currentStatus = currentStatus || current.currentStatus;
3527
3823
  current.keywords = [...new Set([...current.keywords, ...keywords])].slice(0, 8);
3528
- const seenEvidence = new Set(current.evidence.map((item) => `${String(item.chapterId)}|${String(item.quote)}`));
3824
+ const seenEvidence = new Set(current.evidence.map(relationshipEvidenceKey));
3529
3825
  for (const item of evidence) {
3530
- const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
3826
+ const evidenceKey = relationshipEvidenceKey(item);
3531
3827
  if (!seenEvidence.has(evidenceKey))
3532
3828
  current.evidence.push(item);
3533
3829
  }
@@ -3552,20 +3848,30 @@ export class AiManager {
3552
3848
  const durablePeerSubtype = /同事|同僚|共事|搭档|伙伴|朋友|好友|挚友|老友|旧友|战友|盟友|同盟|联盟/u.test(candidate.subtype);
3553
3849
  if (candidate.category !== "social" || !durablePeerSubtype)
3554
3850
  continue;
3555
- const evidenceChapters = new Set(candidate.evidence.map((item) => String(item.chapterId)));
3851
+ const evidenceSources = new Set(candidate.evidence.map((item) => String(item.settingId ?? item.chapterId)));
3556
3852
  const evidenceText = candidate.evidence.map((item) => String(item.quote)).join("\n");
3557
3853
  const explicitlyLongRunning = /同事|同僚|共事|搭档|伙伴|朋友|好友|挚友|老友|旧友|老朋友|战友|盟友|同盟|联盟|结盟|缔盟|盟约|旧识|好久不见|多年|长期|几十年|经常|往日|一直.{0,16}(?:合作|支援|互助|并肩)/u.test(evidenceText);
3558
- if (evidenceChapters.size >= 2 || explicitlyLongRunning)
3854
+ if (evidenceSources.size >= 2 || explicitlyLongRunning)
3559
3855
  continue;
3560
- skipped.push({ index: -1, reason: `“${candidate.subtype}”缺少明确身份或跨章长期互动证据` });
3856
+ skipped.push({ index: -1, reason: candidate.evidence.every((item) => item.contextType === "setting")
3857
+ ? `“${candidate.subtype}”缺少设定集中的明确长期关系表述`
3858
+ : `“${candidate.subtype}”缺少明确身份或跨章长期互动证据` });
3561
3859
  merged.delete(key);
3562
3860
  }
3563
3861
  const relationshipIds = [];
3862
+ const relationshipOutcomes = new Map();
3863
+ const recordRelationshipOutcome = (action, relationship) => {
3864
+ const relationshipId = String(relationship.id);
3865
+ const previous = relationshipOutcomes.get(relationshipId);
3866
+ relationshipOutcomes.set(relationshipId, {
3867
+ action: previous?.action === "created" ? "created" : action,
3868
+ relationship
3869
+ });
3870
+ };
3564
3871
  let replacedRelationshipCount = 0;
3872
+ if (!this.taskCanCommit(taskId))
3873
+ return { interrupted: true, callIds };
3565
3874
  this.store.db.transaction(() => {
3566
- if (!targeted && scope.type === "book") {
3567
- this.store.db.run("DELETE FROM relationships WHERE work_id = ? AND confirmation_status = 'pending' AND locked = 0", workId);
3568
- }
3569
3875
  if (targeted && scope.replaceExistingRelationships === true) {
3570
3876
  const relationshipsToReplace = this.store.listRelationships(workId).filter((relationship) => selectedCharacterIds.has(String(relationship.fromCharacterId)) || selectedCharacterIds.has(String(relationship.toCharacterId)));
3571
3877
  for (const relationship of relationshipsToReplace)
@@ -3573,6 +3879,7 @@ export class AiManager {
3573
3879
  replacedRelationshipCount = relationshipsToReplace.length;
3574
3880
  }
3575
3881
  const existing = this.store.listRelationships(workId).filter((relationship) => relationship.confirmationStatus !== "rejected");
3882
+ const appendOnly = scope.replaceExistingRelationships !== true;
3576
3883
  const unorderedPairKey = (fromCharacterId, toCharacterId) => {
3577
3884
  const pair = [String(fromCharacterId), String(toCharacterId)].sort((left, right) => left.localeCompare(right));
3578
3885
  return `${pair[0]}|${pair[1]}`;
@@ -3690,11 +3997,15 @@ export class AiManager {
3690
3997
  });
3691
3998
  if (duplicateIndex >= 0) {
3692
3999
  const duplicate = existing[duplicateIndex];
4000
+ if (appendOnly) {
4001
+ skipped.push({ index: -1, reason: `已有相同的“${candidate.subtype}”关系,追加模式不更新` });
4002
+ continue;
4003
+ }
3693
4004
  if (duplicate.confirmationStatus === "pending" && duplicate.locked !== true) {
3694
4005
  const mergedEvidence = [...(duplicate.evidence ?? [])];
3695
- const seenEvidence = new Set(mergedEvidence.map((item) => `${String(item.chapterId)}|${String(item.quote)}`));
4006
+ const seenEvidence = new Set(mergedEvidence.map(relationshipEvidenceKey));
3696
4007
  for (const item of candidate.evidence) {
3697
- const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
4008
+ const evidenceKey = relationshipEvidenceKey(item);
3698
4009
  if (!seenEvidence.has(evidenceKey))
3699
4010
  mergedEvidence.push(item);
3700
4011
  }
@@ -3706,6 +4017,10 @@ export class AiManager {
3706
4017
  timeRange: candidate.timeRange,
3707
4018
  evidence: mergedEvidence
3708
4019
  }, "analysis", taskId ?? null, "AI 合并关系证据");
4020
+ recordRelationshipOutcome("updated", existing[duplicateIndex]);
4021
+ }
4022
+ else {
4023
+ recordRelationshipOutcome("unchanged", duplicate);
3709
4024
  }
3710
4025
  if (candidatePeerStrength > 0) {
3711
4026
  for (let index = existing.length - 1; index >= 0; index -= 1) {
@@ -3736,12 +4051,12 @@ export class AiManager {
3736
4051
  && peerSocialStrength(relationship) > 0
3737
4052
  && peerSocialStrength(relationship) < candidatePeerStrength)
3738
4053
  : -1;
3739
- if (weakerExistingPeerIndex >= 0) {
4054
+ if (weakerExistingPeerIndex >= 0 && !appendOnly) {
3740
4055
  const weaker = existing[weakerExistingPeerIndex];
3741
4056
  const mergedEvidence = [...(weaker.evidence ?? [])];
3742
- const seenEvidence = new Set(mergedEvidence.map((item) => `${String(item.chapterId)}|${String(item.quote)}`));
4057
+ const seenEvidence = new Set(mergedEvidence.map(relationshipEvidenceKey));
3743
4058
  for (const item of candidate.evidence) {
3744
- const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
4059
+ const evidenceKey = relationshipEvidenceKey(item);
3745
4060
  if (!seenEvidence.has(evidenceKey))
3746
4061
  mergedEvidence.push(item);
3747
4062
  }
@@ -3753,18 +4068,60 @@ export class AiManager {
3753
4068
  timeRange: candidate.timeRange,
3754
4069
  evidence: mergedEvidence
3755
4070
  }, "analysis", taskId ?? null, "AI 更新关系强度");
4071
+ recordRelationshipOutcome("updated", existing[weakerExistingPeerIndex]);
3756
4072
  continue;
3757
4073
  }
3758
4074
  const relationship = this.store.createRelationship(workId, { ...candidate, confirmationStatus: "pending", locked: false }, "analysis", taskId ?? null);
3759
4075
  relationshipIds.push(String(relationship.id));
4076
+ recordRelationshipOutcome("created", relationship);
3760
4077
  existing.push(relationship);
3761
4078
  }
4079
+ if (taskId && settingsOnly)
4080
+ this.store.refreshTaskSourceVersions(taskId);
4081
+ });
4082
+ const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
4083
+ const relationshipResults = [...relationshipOutcomes.values()].map(({ action, relationship }) => {
4084
+ const evidence = Array.isArray(relationship.evidence)
4085
+ ? relationship.evidence.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
4086
+ : [];
4087
+ return {
4088
+ relationshipId: String(relationship.id),
4089
+ action,
4090
+ fromCharacterId: String(relationship.fromCharacterId),
4091
+ fromCharacterName: characterNameById.get(String(relationship.fromCharacterId)) ?? String(relationship.fromCharacterId),
4092
+ toCharacterId: String(relationship.toCharacterId),
4093
+ toCharacterName: characterNameById.get(String(relationship.toCharacterId)) ?? String(relationship.toCharacterId),
4094
+ category: String(relationship.category),
4095
+ subtype: String(relationship.subtype),
4096
+ keywords: Array.isArray(relationship.keywords) ? relationship.keywords.map(String) : [],
4097
+ directed: Boolean(relationship.directed),
4098
+ currentStatus: String(relationship.currentStatus ?? ""),
4099
+ timeRange: relationship.timeRange && typeof relationship.timeRange === "object" && !Array.isArray(relationship.timeRange)
4100
+ ? relationship.timeRange
4101
+ : {},
4102
+ confidence: Number(relationship.confidence ?? 0),
4103
+ confirmationStatus: String(relationship.confirmationStatus ?? "pending"),
4104
+ evidenceCount: evidence.length,
4105
+ evidence: evidence.slice(0, 3).map((item) => ({
4106
+ chapterId: String(item.chapterId ?? ""),
4107
+ chapterTitle: String(item.chapterTitle ?? chapterById.get(String(item.chapterId))?.title ?? ""),
4108
+ quote: String(item.quote ?? ""),
4109
+ supports: String(item.supports ?? "")
4110
+ })),
4111
+ evidenceTruncated: evidence.length > 3
4112
+ };
3762
4113
  });
4114
+ const createdCount = relationshipResults.filter((item) => item.action === "created").length;
4115
+ const updatedCount = relationshipResults.filter((item) => item.action === "updated").length;
4116
+ const unchangedCount = relationshipResults.filter((item) => item.action === "unchanged").length;
3763
4117
  this.store.audit(workId, "relationship.analysis.completed", "work", workId, {
3764
4118
  batchCount: chunks.length,
3765
4119
  coveredChapterCount: chapters.length,
4120
+ coveredSettingCount: settings.length,
3766
4121
  rawCandidateCount: rawCandidates.length,
3767
4122
  savedCount: relationshipIds.length,
4123
+ updatedCount,
4124
+ unchangedCount,
3768
4125
  skippedCount: skipped.length,
3769
4126
  fallbackSegmentCount,
3770
4127
  policyOmittedSegmentCount,
@@ -3777,10 +4134,25 @@ export class AiManager {
3777
4134
  return {
3778
4135
  relationshipIds,
3779
4136
  candidateCount: relationshipIds.length,
4137
+ createdCount,
4138
+ updatedCount,
4139
+ unchangedCount,
4140
+ relationshipResults,
4141
+ analysisTarget: {
4142
+ mode: targeted ? "targeted-characters" : "all-relationships",
4143
+ scopeType: scope.type,
4144
+ characterIds: [...selectedCharacterIds],
4145
+ characterNames: characters
4146
+ .filter((character) => selectedCharacterIds.has(String(character.id)))
4147
+ .map((character) => String(character.name)),
4148
+ coveredChapterCount: chapters.length,
4149
+ includeAllSettings: scope.includeAllSettings === true
4150
+ },
3780
4151
  rawCandidateCount: rawCandidates.length,
3781
4152
  skipped,
3782
4153
  batchCount: chunks.length,
3783
4154
  coveredChapterCount: chapters.length,
4155
+ coveredSettingCount: settings.length,
3784
4156
  fallbackSegmentCount,
3785
4157
  policyOmittedSegmentCount,
3786
4158
  targetedCharacterIds: [...selectedCharacterIds],
@@ -3876,6 +4248,29 @@ export class AiManager {
3876
4248
  flush();
3877
4249
  return chunks;
3878
4250
  }
4251
+ buildSettingChunks(settings, maximumChars = 10_000) {
4252
+ const chunks = [];
4253
+ let text = "";
4254
+ let settingIds = [];
4255
+ const flush = () => {
4256
+ if (settingIds.length === 0)
4257
+ return;
4258
+ chunks.push({ text, settingIds });
4259
+ text = "";
4260
+ settingIds = [];
4261
+ };
4262
+ for (const setting of settings) {
4263
+ const block = `<SETTING id="${String(setting.id)}" title="${String(setting.title).replaceAll('"', "'")}">\n${String(setting.content)}\n</SETTING>\n`;
4264
+ if (settingIds.length > 0 && text.length + block.length > maximumChars)
4265
+ flush();
4266
+ text += block;
4267
+ settingIds.push(String(setting.id));
4268
+ if (text.length >= maximumChars)
4269
+ flush();
4270
+ }
4271
+ flush();
4272
+ return chunks;
4273
+ }
3879
4274
  splitMarkedChapters(text) {
3880
4275
  const segments = text.match(/<CHAPTER\b[^>]*>[\s\S]*?<\/CHAPTER>/gu) ?? [];
3881
4276
  return segments.length > 0 ? segments : [text];