@musnows/scriverse 0.5.1 → 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,65 +33,44 @@ 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
- const TASK_TRACE_PREVIEW_CHARACTER_LIMIT = 3_000;
37
36
  function traceRecord(value) {
38
37
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
39
38
  }
40
- function previewTaskTraceMessages(messages) {
41
- const records = messages.map(traceRecord);
42
- const contents = records.map((message) => message.content === null ? "" : String(message.content ?? ""));
43
- const allocations = contents.map(() => 0);
44
- let remaining = TASK_TRACE_PREVIEW_CHARACTER_LIMIT;
45
- let active = contents.map((_, index) => index).filter((index) => contents[index].length > 0);
46
- while (remaining > 0 && active.length > 0) {
47
- const share = Math.max(1, Math.floor(remaining / active.length));
48
- const next = [];
49
- for (const index of active) {
50
- if (remaining <= 0)
51
- break;
52
- const available = contents[index].length - allocations[index];
53
- const granted = Math.min(available, share, remaining);
54
- allocations[index] = allocations[index] + granted;
55
- remaining -= granted;
56
- if (allocations[index] < contents[index].length)
57
- next.push(index);
58
- }
59
- active = next;
60
- }
61
- return {
62
- messages: records.map((message, index) => {
63
- const content = contents[index];
64
- const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
65
- return {
66
- role: typeof message.role === "string" ? message.role : "user",
67
- content: content.slice(0, allocations[index]),
68
- contentChars: content.length,
69
- contentTruncated: allocations[index] < content.length,
70
- toolCallCount: toolCalls.length,
71
- ...(typeof message.tool_call_id === "string" ? { tool_call_id: message.tool_call_id } : {})
72
- };
73
- }),
74
- totalChars: contents.reduce((total, content) => total + content.length, 0),
75
- truncated: contents.some((content, index) => allocations[index] < content.length)
76
- };
77
- }
78
- function summarizeTaskTraceRound(value) {
79
- const round = traceRecord(value);
80
- const request = traceRecord(round.request);
81
- const messages = Array.isArray(request.messages) ? request.messages : [];
82
- const attempts = Array.isArray(round.attempts) ? round.attempts : [];
83
- const toolExecutions = Array.isArray(round.toolExecutions) ? round.toolExecutions : [];
84
- return {
85
- round: typeof round.round === "number" ? round.round : 1,
86
- requestedAt: typeof round.requestedAt === "string" ? round.requestedAt : "",
87
- messageCount: messages.length,
88
- promptChars: messages.reduce((total, message) => {
89
- const content = traceRecord(message).content;
90
- return total + (content === null ? 0 : String(content ?? "").length);
91
- }, 0),
92
- attemptCount: attempts.length,
93
- toolExecutionCount: toolExecutions.length
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 });
94
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;
95
74
  }
96
75
  function redactProviderSecret(value, apiKey) {
97
76
  if (!apiKey)
@@ -222,6 +201,11 @@ export function estimateAiTokens(value) {
222
201
  }
223
202
  return Math.max(1, Math.ceil(wideCharacters * 1.1 + narrowCharacters / 4));
224
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
+ }
225
209
  function contextSearchTerms(value) {
226
210
  const normalized = value.normalize("NFKC").toLocaleLowerCase("zh-CN");
227
211
  const terms = new Set();
@@ -573,17 +557,20 @@ export class ContextBuilder {
573
557
  }
574
558
  buildPlan(workId, scope, maximumTokens = 60_000, bookSummaryMaximumTokens, query = "") {
575
559
  const work = this.store.getWork(workId);
576
- const includeAutomaticContext = scope.type !== "none";
560
+ const includeAutomaticContext = scope.type !== "none" && scope.suppressAutomaticContext !== true;
561
+ const settingsOnly = scope.type === "settings";
577
562
  const constraints = includeAutomaticContext
578
563
  ? [`作品:${String(work.title)}\n作者:${String(work.author) || "未填写"}`]
579
564
  : [];
580
565
  const contentSections = [];
581
566
  const availableSettings = this.store.listSettings(workId);
582
- 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);
583
570
  const allCharacters = this.store.listCharacters(workId);
584
571
  const lockedCharacters = allCharacters.filter((item) => Array.isArray(item.lockedFields) && item.lockedFields.length > 0);
585
572
  const organizations = this.store.listOrganizations(workId);
586
- const relationshipConstraints = scope.excludeRelationshipConstraints
573
+ const relationshipConstraints = !includeAutomaticContext || settingsOnly || scope.excludeRelationshipConstraints
587
574
  ? []
588
575
  : selectRelationshipConstraints(this.store, workId, scope.characterIds ?? []);
589
576
  if (includeAutomaticContext && contextualSettings.length > 0) {
@@ -591,7 +578,7 @@ export class ContextBuilder {
591
578
  .map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`)
592
579
  .join("\n")}`);
593
580
  }
594
- if (includeAutomaticContext && lockedCharacters.length > 0) {
581
+ if (includeAutomaticContext && !settingsOnly && lockedCharacters.length > 0) {
595
582
  constraints.push(`作者锁定角色属性(硬约束):\n${lockedCharacters
596
583
  .map((item) => {
597
584
  const locked = item.lockedFields;
@@ -606,7 +593,7 @@ export class ContextBuilder {
606
593
  })
607
594
  .join("\n")}`);
608
595
  }
609
- if (includeAutomaticContext && organizations.length > 0) {
596
+ if (includeAutomaticContext && !settingsOnly && organizations.length > 0) {
610
597
  constraints.push(`世界内组织:\n${organizations.map((item) => {
611
598
  const settings = Array.isArray(item.settings) ? item.settings.map(String).filter(Boolean) : [];
612
599
  const members = Array.isArray(item.members)
@@ -663,6 +650,9 @@ export class ContextBuilder {
663
650
  }
664
651
  }
665
652
  }
653
+ else if (scope.type === "settings" && scope.selection) {
654
+ contentSections.push(`待分析设定:\n${scope.selection}`);
655
+ }
666
656
  if (scope.includeBookSummary || scope.type === "book" || scope.type === "volume") {
667
657
  this.appendBookSummary(contentSections, workId, bookSummaryMaximumTokens ?? Math.max(160, Math.floor(maximumTokens * 0.35)), query, scope.type === "volume" ? scope.volumeId : undefined);
668
658
  }
@@ -691,7 +681,9 @@ export class ContextBuilder {
691
681
  if (setting.workId !== workId)
692
682
  throw new AppError(400, "SETTING_WORK_MISMATCH", "设定不属于当前作品");
693
683
  }
694
- 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")}`);
695
687
  }
696
688
  if (scope.chapterIds?.length) {
697
689
  const chapterIds = [...new Set(scope.chapterIds)]
@@ -718,7 +710,7 @@ export class ContextBuilder {
718
710
  });
719
711
  }
720
712
  const sections = contentSections.map((text, order) => {
721
- const required = /^(?:当前选中文本|当前章节|所在章节|作者主动引用的章节)/u.test(text);
713
+ const required = /^(?:当前选中文本|当前章节|所在章节|作者主动引用的章节|待分析设定)/u.test(text);
722
714
  const summary = /章节概要(/u.test(text);
723
715
  return {
724
716
  id: `context-${order}`,
@@ -1372,9 +1364,10 @@ export class AiManager {
1372
1364
  })), pagination);
1373
1365
  }
1374
1366
  getTaskTrace(taskId) {
1375
- this.store.getTask(taskId);
1367
+ this.store.getTaskWorkId(taskId);
1376
1368
  const rows = this.store.db.all(`SELECT call.id, call.task_type, call.provider_id, call.model_id, call.status, call.failure,
1377
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,
1378
1371
  CASE WHEN trace.call_id IS NULL THEN 0 ELSE json_array_length(trace.initial_messages_json) END AS initial_message_count,
1379
1372
  CASE WHEN trace.call_id IS NULL THEN 0 ELSE json_array_length(trace.rounds_json) END AS round_count,
1380
1373
  CASE WHEN trace.call_id IS NULL THEN 0 ELSE length(trace.initial_messages_json) + length(trace.rounds_json) END AS trace_chars,
@@ -1389,6 +1382,9 @@ export class AiManager {
1389
1382
  const calls = rows.map((row) => {
1390
1383
  const hasTrace = row.trace_call_id !== null && row.trace_call_id !== undefined;
1391
1384
  const failure = row.failure === null ? null : stringValue(row, "failure");
1385
+ const sourceRefs = hasTrace
1386
+ ? json(stringValue(row, "source_refs_json"), [])
1387
+ : [];
1392
1388
  return {
1393
1389
  id: stringValue(row, "id"),
1394
1390
  taskType: stringValue(row, "task_type"),
@@ -1410,6 +1406,7 @@ export class AiManager {
1410
1406
  outputChars: numberValue(row, "output_chars"),
1411
1407
  createdAt: stringValue(row, "created_at"),
1412
1408
  completedAt: row.completed_at === null ? null : stringValue(row, "completed_at"),
1409
+ sourceRefs,
1413
1410
  trace: hasTrace ? {
1414
1411
  available: true,
1415
1412
  initialMessageCount: numberValue(row, "initial_message_count"),
@@ -1426,8 +1423,8 @@ export class AiManager {
1426
1423
  calls
1427
1424
  };
1428
1425
  }
1429
- getTaskTraceCall(taskId, callId, full = false) {
1430
- this.store.getTask(taskId);
1426
+ getTaskTraceCall(taskId, callId) {
1427
+ this.store.getTaskWorkId(taskId);
1431
1428
  const row = this.store.db.get(`SELECT trace.initial_messages_json, trace.rounds_json, trace.created_at, trace.updated_at
1432
1429
  FROM ai_calls call JOIN ai_call_traces trace ON trace.call_id = call.id AND trace.task_id = call.task_id
1433
1430
  WHERE call.id = ? AND call.task_id = ?`, callId, taskId);
@@ -1435,30 +1432,13 @@ export class AiManager {
1435
1432
  throw notFound("AI 调用追踪");
1436
1433
  const initialMessages = json(stringValue(row, "initial_messages_json"), []);
1437
1434
  const rounds = json(stringValue(row, "rounds_json"), []);
1438
- if (full) {
1439
- return {
1440
- taskId,
1441
- callId,
1442
- mode: "full",
1443
- trace: {
1444
- initialMessages,
1445
- rounds,
1446
- createdAt: stringValue(row, "created_at"),
1447
- updatedAt: stringValue(row, "updated_at")
1448
- }
1449
- };
1450
- }
1451
- const preview = previewTaskTraceMessages(initialMessages);
1452
1435
  return {
1453
1436
  taskId,
1454
1437
  callId,
1455
- mode: "preview",
1456
- previewLimit: TASK_TRACE_PREVIEW_CHARACTER_LIMIT,
1457
- truncated: preview.truncated,
1458
- totalPromptChars: preview.totalChars,
1438
+ mode: "full",
1459
1439
  trace: {
1460
- initialMessages: preview.messages,
1461
- rounds: rounds.map(summarizeTaskTraceRound),
1440
+ initialMessages,
1441
+ rounds,
1462
1442
  createdAt: stringValue(row, "created_at"),
1463
1443
  updatedAt: stringValue(row, "updated_at")
1464
1444
  }
@@ -1754,7 +1734,7 @@ export class AiManager {
1754
1734
  return this.contextBuilder.buildPlan(input.workId, input.scope, workContextBudgetTokens, bookSummaryMaximumTokens, input.instruction);
1755
1735
  }
1756
1736
  buildContext(input, model) {
1757
- return this.buildContextPlan(input, model).context;
1737
+ return collapseAiBlankLines(this.buildContextPlan(input, model).context);
1758
1738
  }
1759
1739
  enabledAgentToolIds(workId, taskType, requestedToolIds) {
1760
1740
  if (taskType !== "chat" && requestedToolIds === undefined)
@@ -1862,7 +1842,7 @@ export class AiManager {
1862
1842
  const chapter = this.store.getChapter(chapterId);
1863
1843
  if (chapter.workId !== workId)
1864
1844
  return { chapterId, error: { code: "CHAPTER_WORK_MISMATCH", message: "The requested chapter belongs to a different work." } };
1865
- const content = String(chapter.content);
1845
+ const content = collapseAiBlankLines(String(chapter.content));
1866
1846
  const excerpt = content.slice(0, Math.max(0, remainingChars));
1867
1847
  remainingChars -= excerpt.length;
1868
1848
  return { chapterId, title: chapter.title, versionNo: chapter.versionNo, ...(include !== "content" ? { summary: summaries.get(chapterId) ?? "" } : {}), ...(include !== "summary" ? { content: excerpt, contentTruncated: excerpt.length < content.length } : {}) };
@@ -1924,7 +1904,7 @@ export class AiManager {
1924
1904
  const section = this.store.getCharacterProfileSection(sectionId);
1925
1905
  if (section.workId !== workId)
1926
1906
  return { sectionId, error: { code: "CHARACTER_SECTION_WORK_MISMATCH", message: "The requested character section belongs to a different work." } };
1927
- const content = String(section.contentMarkdown);
1907
+ const content = collapseAiBlankLines(String(section.contentMarkdown));
1928
1908
  const excerpt = content.slice(0, Math.max(0, remainingChars));
1929
1909
  remainingChars -= excerpt.length;
1930
1910
  const character = this.store.getCharacter(String(section.characterId));
@@ -1985,14 +1965,14 @@ export class AiManager {
1985
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,
1986
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);
1987
1967
  if (input.taskId) {
1988
- this.store.db.run(`INSERT INTO ai_call_traces (call_id, task_id, initial_messages_json, rounds_json, created_at, updated_at)
1989
- VALUES (?, ?, ?, '[]', ?, ?)`, callId, input.taskId, JSON.stringify(messages), timestamp, timestamp);
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);
1990
1970
  }
1991
1971
  });
1992
1972
  const saveTrace = () => {
1993
1973
  if (!input.taskId)
1994
1974
  return;
1995
- 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);
1996
1976
  };
1997
1977
  const callStartedAt = process.hrtime.bigint();
1998
1978
  logger.info("ai.call.started", {
@@ -3317,10 +3297,141 @@ export class AiManager {
3317
3297
  }
3318
3298
  };
3319
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
+ }
3320
3430
  async runRelationshipAnalysis(workId, scope, modelId, taskId) {
3321
3431
  const characters = this.store.listCharacters(workId);
3322
3432
  if (characters.length < 2)
3323
3433
  throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
3434
+ const settingsOnly = scope.type === "settings";
3324
3435
  const selectedCharacterIds = new Set(scope.characterIds ?? []);
3325
3436
  for (const characterId of selectedCharacterIds) {
3326
3437
  const character = characters.find((item) => item.id === characterId);
@@ -3332,33 +3443,87 @@ export class AiManager {
3332
3443
  .filter((character) => selectedCharacterIds.has(String(character.id)))
3333
3444
  .map((character) => `${String(character.id)} | ${String(character.name)}`)
3334
3445
  .join("\n");
3335
- const chapters = this.getScopeChapters(workId, scope);
3336
- if (chapters.length === 0)
3337
- throw new AppError(409, "CHAPTERS_REQUIRED", "人物关系分析范围内没有章节");
3338
- const chunks = this.buildChapterChunks(chapters, 12_000);
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
+ }
3339
3484
  const concurrency = this.configuredConcurrency(workId, "relationship-analysis", modelId);
3340
3485
  const roster = characters.map((character) => {
3341
3486
  const aliases = character.aliases.filter((alias) => this.isSafeGlobalAlias(alias));
3342
3487
  return `${String(character.id)} | ${String(character.name)}${aliases.length ? ` | 别名:${aliases.join("、")}` : ""}`;
3343
3488
  }).join("\n");
3344
3489
  const rawCandidates = [];
3490
+ const chapterEvidenceCandidates = [];
3491
+ const settingCandidates = [];
3345
3492
  const callIds = [];
3346
- 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) => {
3347
3511
  const generated = await this.generateTaggedJson({
3348
3512
  workId,
3349
3513
  taskId,
3350
3514
  taskType: "relationship-analysis",
3351
3515
  signal: this.taskSignal(taskId),
3352
3516
  maxAttempts,
3353
- scope: {
3354
- type: "selection",
3355
- selection: text,
3356
- includeAllSettings: scope.includeAllSettings,
3357
- ...(targeted ? { characterIds: [...selectedCharacterIds], excludeRelationshipConstraints: scope.replaceExistingRelationships === true } : {})
3358
- },
3517
+ scope: chunk.sourceKind === "setting"
3518
+ ? { type: "settings", selection: chunk.text }
3519
+ : {
3520
+ type: "selection",
3521
+ selection: chunk.text,
3522
+ ...(targeted ? { suppressAutomaticContext: true } : {})
3523
+ },
3359
3524
  ...(modelId ? { modelId } : {}),
3360
3525
  parameters: { temperature: 0.1 },
3361
- instruction: targeted ? [
3526
+ instruction: chunk.sourceKind === "setting" ? settingsInstruction : targeted ? [
3362
3527
  "你是定向人物关系证据收集器。本阶段只建立跨章节证据账本,不下最终关系结论。",
3363
3528
  "被分析角色:",
3364
3529
  targetedRoster,
@@ -3403,9 +3568,11 @@ export class AiManager {
3403
3568
  "24. 共同执行一次任务、同属一个组织、在同一集体场景中被感谢或落泪、替第三人转发消息,都不能单独证明同事、朋友或盟友。此类关系必须有原文明示身份,或至少两个不同章节的持续互动证据。"
3404
3569
  ].join("\n"),
3405
3570
  extraSystemPrompt: [
3406
- targeted
3407
- ? "你正在为指定角色收集可审计的跨章节关系线索。不得在证据收集阶段把单次互动直接判定为长期关系。"
3408
- : "关系候选必须可审计。严禁把梦境伴侣、醉后梦话、单次约定、同章共现、礼称、同族归属、救援照护或类比提及写成现实长期关系。逐句校验说话人和关系方向。",
3571
+ chunk.sourceKind === "setting"
3572
+ ? "本次只允许使用提供的系统设定条目。每条结论都必须能回溯到对应 settingId 的原文引文。"
3573
+ : targeted
3574
+ ? "你正在为指定角色收集可审计的跨章节关系线索。不得在证据收集阶段把单次互动直接判定为长期关系。"
3575
+ : "关系候选必须可审计。严禁把梦境伴侣、醉后梦话、单次约定、同章共现、礼称、同族归属、救援照护或类比提及写成现实长期关系。逐句校验说话人和关系方向。",
3409
3576
  scope.additionalPrompt?.trim() ? `作者追加的关系分析提示:\n${scope.additionalPrompt.trim()}` : ""
3410
3577
  ].filter(Boolean).join("\n\n")
3411
3578
  });
@@ -3419,26 +3586,32 @@ export class AiManager {
3419
3586
  };
3420
3587
  const chunkResults = await this.processChunks(chunks, concurrency, async (chunk) => {
3421
3588
  if (taskId && this.store.getTask(taskId).status !== "running") {
3422
- return { candidates: [], callIds: [], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
3589
+ return { sourceKind: chunk.sourceKind, candidates: [], callIds: [], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
3423
3590
  }
3424
3591
  try {
3425
- const extracted = await extractChunk(chunk.text, 1);
3426
- 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 };
3427
3594
  }
3428
3595
  catch {
3596
+ if (chunk.sourceKind === "setting")
3597
+ throw new AppError(502, "AI_SETTINGS_BATCH_FAILED", "设定数据人物关系分析批次失败");
3429
3598
  const segments = this.splitMarkedChapters(chunk.text);
3430
- 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 };
3431
3601
  }
3432
3602
  }, (completed) => {
3433
3603
  if (taskId && this.store.getTask(taskId).status === "running") {
3434
- const maximumProgress = targeted ? 72 : 92;
3604
+ const maximumProgress = targeted && !settingsOnly ? 72 : 92;
3435
3605
  this.store.updateTask(taskId, { status: "running", progress: Math.min(maximumProgress, 5 + Math.round(completed / chunks.length * (maximumProgress - 5))) });
3436
3606
  }
3437
3607
  });
3438
3608
  let fallbackSegmentCount = 0;
3439
3609
  let policyOmittedSegmentCount = 0;
3440
3610
  for (const result of chunkResults) {
3441
- rawCandidates.push(...result.candidates);
3611
+ if (result.sourceKind === "setting")
3612
+ settingCandidates.push(...result.candidates);
3613
+ else
3614
+ chapterEvidenceCandidates.push(...result.candidates);
3442
3615
  callIds.push(...result.callIds);
3443
3616
  fallbackSegmentCount += result.fallbackSegmentCount;
3444
3617
  policyOmittedSegmentCount += result.policyOmittedSegmentCount;
@@ -3453,11 +3626,13 @@ export class AiManager {
3453
3626
  batchCount: chunks.length
3454
3627
  });
3455
3628
  }
3456
- const targetedEvidenceCount = targeted ? rawCandidates.length : 0;
3629
+ if (!targeted)
3630
+ rawCandidates.push(...chapterEvidenceCandidates, ...settingCandidates);
3631
+ const targetedEvidenceCount = targeted ? chapterEvidenceCandidates.length + settingCandidates.length : 0;
3457
3632
  let aggregationBatchCount = 0;
3458
- if (targeted && rawCandidates.length > 0) {
3633
+ if (targeted && !settingsOnly && chapterEvidenceCandidates.length > 0) {
3459
3634
  const evidenceGroups = new Map();
3460
- for (const evidence of rawCandidates) {
3635
+ for (const evidence of chapterEvidenceCandidates) {
3461
3636
  const target = String(evidence.targetCharacterId ?? "");
3462
3637
  const related = String(evidence.relatedCharacterId ?? evidence.relatedReference ?? "unknown");
3463
3638
  const key = `${target}|${related}`;
@@ -3490,9 +3665,7 @@ export class AiManager {
3490
3665
  maxAttempts: 2,
3491
3666
  scope: {
3492
3667
  type: "entities",
3493
- includeAllSettings: scope.includeAllSettings,
3494
- characterIds: [...selectedCharacterIds],
3495
- excludeRelationshipConstraints: scope.replaceExistingRelationships === true
3668
+ suppressAutomaticContext: true
3496
3669
  },
3497
3670
  ...(modelId ? { modelId } : {}),
3498
3671
  parameters: { temperature: 0.1 },
@@ -3532,10 +3705,14 @@ export class AiManager {
3532
3705
  this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 72 + Math.round(completed / evidenceBatches.length * 20)) });
3533
3706
  }
3534
3707
  });
3535
- rawCandidates.splice(0, rawCandidates.length, ...aggregationResults.flatMap((result) => result.candidates));
3708
+ rawCandidates.push(...aggregationResults.flatMap((result) => result.candidates));
3536
3709
  callIds.push(...aggregationResults.map((result) => result.callId));
3537
3710
  }
3711
+ if (targeted)
3712
+ rawCandidates.push(...settingCandidates);
3538
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)}`;
3539
3716
  const categories = new Set(["family", "social", "emotional", "conflict", "uncertain"]);
3540
3717
  const merged = new Map();
3541
3718
  const skipped = [];
@@ -3584,21 +3761,36 @@ export class AiManager {
3584
3761
  [fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
3585
3762
  const evidence = (Array.isArray(candidate.evidence) ? candidate.evidence : [])
3586
3763
  .filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
3587
- .filter((item) => {
3588
- if (typeof item.chapterId !== "string" || typeof item.quote !== "string" || item.quote.trim().length > 80)
3589
- return false;
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 [];
3590
3781
  const chapter = chapterById.get(item.chapterId);
3591
- return Boolean(chapter && this.quoteExists(String(chapter.content), item.quote));
3592
- })
3593
- .map((item) => ({
3594
- chapterId: item.chapterId,
3595
- chapterTitle: String(chapterById.get(String(item.chapterId))?.title ?? ""),
3596
- quote: String(item.quote).trim(),
3597
- contextType: typeof item.contextType === "string" ? item.contextType : "current",
3598
- supports: typeof item.supports === "string" ? item.supports : ""
3599
- }));
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
+ });
3600
3792
  if (evidence.length === 0) {
3601
- skipped.push({ index, reason: "证据引文未在对应章节原文命中" });
3793
+ skipped.push({ index, reason: "证据引文未在对应章节或设定条目命中" });
3602
3794
  return;
3603
3795
  }
3604
3796
  const evidenceText = evidence.map((item) => String(item.quote)).join("\n");
@@ -3618,9 +3810,9 @@ export class AiManager {
3618
3810
  }
3619
3811
  }
3620
3812
  if (category === "conflict" && subtype === "宿敌") {
3621
- const evidenceChapters = new Set(evidence.map((item) => String(item.chapterId)));
3813
+ const evidenceSources = new Set(evidence.map((item) => String(item.settingId ?? item.chapterId)));
3622
3814
  const explicitlyLongRunning = /宿敌|世仇|死敌|多年|长期|世代|一直.{0,24}(?:敌|威胁|对抗|杀手)|远古.{0,16}(?:战|敌)|多次.{0,16}(?:交战|对抗|冲突)/u.test(evidenceText);
3623
- if (evidenceChapters.size < 2 && !explicitlyLongRunning)
3815
+ if (evidenceSources.size < 2 && !explicitlyLongRunning)
3624
3816
  subtype = "战时敌对";
3625
3817
  }
3626
3818
  const key = [fromCharacterId, toCharacterId, category, this.normalizeReference(subtype), directed ? "1" : "0"].join("|");
@@ -3629,9 +3821,9 @@ export class AiManager {
3629
3821
  current.confidence = Math.max(current.confidence, confidence);
3630
3822
  current.currentStatus = currentStatus || current.currentStatus;
3631
3823
  current.keywords = [...new Set([...current.keywords, ...keywords])].slice(0, 8);
3632
- const seenEvidence = new Set(current.evidence.map((item) => `${String(item.chapterId)}|${String(item.quote)}`));
3824
+ const seenEvidence = new Set(current.evidence.map(relationshipEvidenceKey));
3633
3825
  for (const item of evidence) {
3634
- const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
3826
+ const evidenceKey = relationshipEvidenceKey(item);
3635
3827
  if (!seenEvidence.has(evidenceKey))
3636
3828
  current.evidence.push(item);
3637
3829
  }
@@ -3656,20 +3848,30 @@ export class AiManager {
3656
3848
  const durablePeerSubtype = /同事|同僚|共事|搭档|伙伴|朋友|好友|挚友|老友|旧友|战友|盟友|同盟|联盟/u.test(candidate.subtype);
3657
3849
  if (candidate.category !== "social" || !durablePeerSubtype)
3658
3850
  continue;
3659
- 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)));
3660
3852
  const evidenceText = candidate.evidence.map((item) => String(item.quote)).join("\n");
3661
3853
  const explicitlyLongRunning = /同事|同僚|共事|搭档|伙伴|朋友|好友|挚友|老友|旧友|老朋友|战友|盟友|同盟|联盟|结盟|缔盟|盟约|旧识|好久不见|多年|长期|几十年|经常|往日|一直.{0,16}(?:合作|支援|互助|并肩)/u.test(evidenceText);
3662
- if (evidenceChapters.size >= 2 || explicitlyLongRunning)
3854
+ if (evidenceSources.size >= 2 || explicitlyLongRunning)
3663
3855
  continue;
3664
- 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}”缺少明确身份或跨章长期互动证据` });
3665
3859
  merged.delete(key);
3666
3860
  }
3667
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
+ };
3668
3871
  let replacedRelationshipCount = 0;
3872
+ if (!this.taskCanCommit(taskId))
3873
+ return { interrupted: true, callIds };
3669
3874
  this.store.db.transaction(() => {
3670
- if (!targeted && scope.type === "book") {
3671
- this.store.db.run("DELETE FROM relationships WHERE work_id = ? AND confirmation_status = 'pending' AND locked = 0", workId);
3672
- }
3673
3875
  if (targeted && scope.replaceExistingRelationships === true) {
3674
3876
  const relationshipsToReplace = this.store.listRelationships(workId).filter((relationship) => selectedCharacterIds.has(String(relationship.fromCharacterId)) || selectedCharacterIds.has(String(relationship.toCharacterId)));
3675
3877
  for (const relationship of relationshipsToReplace)
@@ -3677,6 +3879,7 @@ export class AiManager {
3677
3879
  replacedRelationshipCount = relationshipsToReplace.length;
3678
3880
  }
3679
3881
  const existing = this.store.listRelationships(workId).filter((relationship) => relationship.confirmationStatus !== "rejected");
3882
+ const appendOnly = scope.replaceExistingRelationships !== true;
3680
3883
  const unorderedPairKey = (fromCharacterId, toCharacterId) => {
3681
3884
  const pair = [String(fromCharacterId), String(toCharacterId)].sort((left, right) => left.localeCompare(right));
3682
3885
  return `${pair[0]}|${pair[1]}`;
@@ -3794,11 +3997,15 @@ export class AiManager {
3794
3997
  });
3795
3998
  if (duplicateIndex >= 0) {
3796
3999
  const duplicate = existing[duplicateIndex];
4000
+ if (appendOnly) {
4001
+ skipped.push({ index: -1, reason: `已有相同的“${candidate.subtype}”关系,追加模式不更新` });
4002
+ continue;
4003
+ }
3797
4004
  if (duplicate.confirmationStatus === "pending" && duplicate.locked !== true) {
3798
4005
  const mergedEvidence = [...(duplicate.evidence ?? [])];
3799
- const seenEvidence = new Set(mergedEvidence.map((item) => `${String(item.chapterId)}|${String(item.quote)}`));
4006
+ const seenEvidence = new Set(mergedEvidence.map(relationshipEvidenceKey));
3800
4007
  for (const item of candidate.evidence) {
3801
- const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
4008
+ const evidenceKey = relationshipEvidenceKey(item);
3802
4009
  if (!seenEvidence.has(evidenceKey))
3803
4010
  mergedEvidence.push(item);
3804
4011
  }
@@ -3810,6 +4017,10 @@ export class AiManager {
3810
4017
  timeRange: candidate.timeRange,
3811
4018
  evidence: mergedEvidence
3812
4019
  }, "analysis", taskId ?? null, "AI 合并关系证据");
4020
+ recordRelationshipOutcome("updated", existing[duplicateIndex]);
4021
+ }
4022
+ else {
4023
+ recordRelationshipOutcome("unchanged", duplicate);
3813
4024
  }
3814
4025
  if (candidatePeerStrength > 0) {
3815
4026
  for (let index = existing.length - 1; index >= 0; index -= 1) {
@@ -3840,12 +4051,12 @@ export class AiManager {
3840
4051
  && peerSocialStrength(relationship) > 0
3841
4052
  && peerSocialStrength(relationship) < candidatePeerStrength)
3842
4053
  : -1;
3843
- if (weakerExistingPeerIndex >= 0) {
4054
+ if (weakerExistingPeerIndex >= 0 && !appendOnly) {
3844
4055
  const weaker = existing[weakerExistingPeerIndex];
3845
4056
  const mergedEvidence = [...(weaker.evidence ?? [])];
3846
- const seenEvidence = new Set(mergedEvidence.map((item) => `${String(item.chapterId)}|${String(item.quote)}`));
4057
+ const seenEvidence = new Set(mergedEvidence.map(relationshipEvidenceKey));
3847
4058
  for (const item of candidate.evidence) {
3848
- const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
4059
+ const evidenceKey = relationshipEvidenceKey(item);
3849
4060
  if (!seenEvidence.has(evidenceKey))
3850
4061
  mergedEvidence.push(item);
3851
4062
  }
@@ -3857,18 +4068,60 @@ export class AiManager {
3857
4068
  timeRange: candidate.timeRange,
3858
4069
  evidence: mergedEvidence
3859
4070
  }, "analysis", taskId ?? null, "AI 更新关系强度");
4071
+ recordRelationshipOutcome("updated", existing[weakerExistingPeerIndex]);
3860
4072
  continue;
3861
4073
  }
3862
4074
  const relationship = this.store.createRelationship(workId, { ...candidate, confirmationStatus: "pending", locked: false }, "analysis", taskId ?? null);
3863
4075
  relationshipIds.push(String(relationship.id));
4076
+ recordRelationshipOutcome("created", relationship);
3864
4077
  existing.push(relationship);
3865
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
+ };
3866
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;
3867
4117
  this.store.audit(workId, "relationship.analysis.completed", "work", workId, {
3868
4118
  batchCount: chunks.length,
3869
4119
  coveredChapterCount: chapters.length,
4120
+ coveredSettingCount: settings.length,
3870
4121
  rawCandidateCount: rawCandidates.length,
3871
4122
  savedCount: relationshipIds.length,
4123
+ updatedCount,
4124
+ unchangedCount,
3872
4125
  skippedCount: skipped.length,
3873
4126
  fallbackSegmentCount,
3874
4127
  policyOmittedSegmentCount,
@@ -3881,10 +4134,25 @@ export class AiManager {
3881
4134
  return {
3882
4135
  relationshipIds,
3883
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
+ },
3884
4151
  rawCandidateCount: rawCandidates.length,
3885
4152
  skipped,
3886
4153
  batchCount: chunks.length,
3887
4154
  coveredChapterCount: chapters.length,
4155
+ coveredSettingCount: settings.length,
3888
4156
  fallbackSegmentCount,
3889
4157
  policyOmittedSegmentCount,
3890
4158
  targetedCharacterIds: [...selectedCharacterIds],
@@ -3980,6 +4248,29 @@ export class AiManager {
3980
4248
  flush();
3981
4249
  return chunks;
3982
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
+ }
3983
4274
  splitMarkedChapters(text) {
3984
4275
  const segments = text.match(/<CHAPTER\b[^>]*>[\s\S]*?<\/CHAPTER>/gu) ?? [];
3985
4276
  return segments.length > 0 ? segments : [text];