@musnows/scriverse 0.4.11 → 0.4.12

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
@@ -202,6 +202,43 @@ export function resolveOutputTokens(usage, content) {
202
202
  }
203
203
  return estimateAiTokens(content);
204
204
  }
205
+ function resolveInputCacheUsage(usage) {
206
+ if (!usage || typeof usage !== "object")
207
+ return null;
208
+ const record = usage;
209
+ const promptDetails = record.prompt_tokens_details && typeof record.prompt_tokens_details === "object"
210
+ ? record.prompt_tokens_details
211
+ : {};
212
+ const inputDetails = record.input_tokens_details && typeof record.input_tokens_details === "object"
213
+ ? record.input_tokens_details
214
+ : {};
215
+ const cached = promptDetails.cached_tokens
216
+ ?? inputDetails.cached_tokens
217
+ ?? record.prompt_cache_hit_tokens
218
+ ?? record.cache_read_input_tokens
219
+ ?? record.cached_input_tokens;
220
+ if (typeof cached !== "number" || !Number.isFinite(cached))
221
+ return null;
222
+ const reportedInput = record.prompt_tokens ?? record.input_tokens;
223
+ const missed = record.prompt_cache_miss_tokens;
224
+ const inputTokens = typeof reportedInput === "number" && Number.isFinite(reportedInput)
225
+ ? Math.max(0, Math.round(reportedInput))
226
+ : typeof missed === "number" && Number.isFinite(missed)
227
+ ? Math.max(0, Math.round(cached)) + Math.max(0, Math.round(missed))
228
+ : 0;
229
+ if (inputTokens <= 0)
230
+ return null;
231
+ return {
232
+ inputTokens,
233
+ cachedInputTokens: Math.min(inputTokens, Math.max(0, Math.round(cached)))
234
+ };
235
+ }
236
+ export function resolveCacheHitPercent(usage) {
237
+ const resolved = resolveInputCacheUsage(usage);
238
+ if (!resolved)
239
+ return undefined;
240
+ return Math.round(resolved.cachedInputTokens / resolved.inputTokens * 1_000) / 10;
241
+ }
205
242
  function normalizeModelPreset(input, modelId = "") {
206
243
  const maxTokens = typeof input.max_tokens === "number" && Number.isFinite(input.max_tokens)
207
244
  ? Math.round(clamp(input.max_tokens, 1, 32_768))
@@ -428,13 +465,16 @@ export class ContextBuilder {
428
465
  ? [`作品:${String(work.title)}\n作者:${String(work.author) || "未填写"}`]
429
466
  : [];
430
467
  const contentSections = [];
431
- const lockedSettings = this.store.listSettings(workId).filter((item) => item.locked);
468
+ const availableSettings = this.store.listSettings(workId);
469
+ const contextualSettings = scope.includeAllSettings ? availableSettings : availableSettings.filter((item) => item.locked);
432
470
  const allCharacters = this.store.listCharacters(workId);
433
471
  const lockedCharacters = allCharacters.filter((item) => Array.isArray(item.lockedFields) && item.lockedFields.length > 0);
434
472
  const organizations = this.store.listOrganizations(workId);
435
- const relationshipConstraints = selectRelationshipConstraints(this.store, workId, scope.characterIds ?? []);
436
- if (includeAutomaticContext && lockedSettings.length > 0) {
437
- constraints.push(`作者锁定设定(硬约束):\n${lockedSettings
473
+ const relationshipConstraints = scope.excludeRelationshipConstraints
474
+ ? []
475
+ : selectRelationshipConstraints(this.store, workId, scope.characterIds ?? []);
476
+ if (includeAutomaticContext && contextualSettings.length > 0) {
477
+ constraints.push(`${scope.includeAllSettings ? "全部作品设定(关系分析参考)" : "作者锁定设定(硬约束)"}:\n${contextualSettings
438
478
  .map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`)
439
479
  .join("\n")}`);
440
480
  }
@@ -1020,7 +1060,7 @@ export class AiManager {
1020
1060
  source_text, content, action, status, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, suggestionId, generated.callId, input.workId, chapter ? String(chapter.id) : null, chapter ? Number(chapter.versionNo) : null, input.taskType, input.instruction, effectiveInput.scope.selection ?? "", generated.content, action, now(), currentRequestActor()?.userId ?? null);
1021
1061
  if (input.taskType === "continue")
1022
1062
  await this.runSuggestionGuard(suggestionId);
1023
- return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, toolCalls: generated.toolCalls, processSteps: generated.processSteps };
1063
+ return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, ...(generated.cacheHitPercent === undefined ? {} : { cacheHitPercent: generated.cacheHitPercent }), toolCalls: generated.toolCalls, processSteps: generated.processSteps };
1024
1064
  }
1025
1065
  async createStreamingChat(input, onDelta) {
1026
1066
  const generated = this.enabledAgentTools(input.workId, "chat").length
@@ -1032,7 +1072,7 @@ export class AiManager {
1032
1072
  const suggestionId = id("suggestion");
1033
1073
  this.store.db.run(`INSERT INTO ai_suggestions (id, call_id, work_id, chapter_id, chapter_version, task_type, instruction,
1034
1074
  source_text, content, action, status, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, 'chat', ?, ?, ?, 'note', 'pending', ?, ?)`, suggestionId, generated.callId, input.workId, chapter ? String(chapter.id) : null, chapter ? Number(chapter.versionNo) : null, input.instruction, input.scope.selection ?? "", generated.content, now(), currentRequestActor()?.userId ?? null);
1035
- return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, toolCalls: generated.toolCalls, processSteps: generated.processSteps };
1075
+ return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, ...(generated.cacheHitPercent === undefined ? {} : { cacheHitPercent: generated.cacheHitPercent }), toolCalls: generated.toolCalls, processSteps: generated.processSteps };
1036
1076
  }
1037
1077
  async runSuggestionGuard(suggestionId, candidateContent) {
1038
1078
  const suggestion = this.getSuggestion(suggestionId);
@@ -1750,6 +1790,10 @@ export class AiManager {
1750
1790
  const endpoint = `${normalizeBaseUrl(stringValue(provider, "base_url"))}/chat/completions`;
1751
1791
  const timeoutMs = input.taskType === "book-analysis" || input.taskType === "relationship-analysis" ? 300_000 : 60_000;
1752
1792
  const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
1793
+ let completionRequestCount = 0;
1794
+ let cacheUsageComplete = true;
1795
+ let totalInputTokens = 0;
1796
+ let totalCachedInputTokens = 0;
1753
1797
  const requestCompletion = async (toolChoice) => {
1754
1798
  let lastFailure = null;
1755
1799
  for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
@@ -1793,7 +1837,16 @@ export class AiManager {
1793
1837
  });
1794
1838
  if (candidate.ok) {
1795
1839
  try {
1796
- return JSON.parse(candidate.body);
1840
+ const parsed = JSON.parse(candidate.body);
1841
+ completionRequestCount += 1;
1842
+ const cacheUsage = resolveInputCacheUsage(parsed.usage);
1843
+ if (!cacheUsage)
1844
+ cacheUsageComplete = false;
1845
+ else {
1846
+ totalInputTokens += cacheUsage.inputTokens;
1847
+ totalCachedInputTokens += cacheUsage.cachedInputTokens;
1848
+ }
1849
+ return parsed;
1797
1850
  }
1798
1851
  catch {
1799
1852
  throw new Error(`Chat Completions returned invalid JSON: ${candidate.body.slice(0, 500)}`);
@@ -1897,6 +1950,9 @@ export class AiManager {
1897
1950
  }
1898
1951
  this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
1899
1952
  const outputTokens = resolveOutputTokens(payload.usage, content);
1953
+ const cacheHitPercent = cacheUsageComplete && completionRequestCount > 0 && totalInputTokens > 0
1954
+ ? Math.round(totalCachedInputTokens / totalInputTokens * 1_000) / 10
1955
+ : undefined;
1900
1956
  logger.info("ai.call.completed", {
1901
1957
  callId,
1902
1958
  workId: input.workId,
@@ -1907,7 +1963,7 @@ export class AiManager {
1907
1963
  outputTokens,
1908
1964
  toolCallCount: executedToolCalls.length
1909
1965
  });
1910
- return { callId, content, outputTokens, provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: executedToolCalls, processSteps };
1966
+ return { callId, content, outputTokens, ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }), provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: executedToolCalls, processSteps };
1911
1967
  }
1912
1968
  catch (error) {
1913
1969
  const message = error instanceof Error ? error.message : "AI 调用失败";
@@ -2024,7 +2080,7 @@ export class AiManager {
2024
2080
  }
2025
2081
  if (streamedResult === null)
2026
2082
  throw lastFailure instanceof Error ? lastFailure : new Error("AI 流式请求重试后仍未返回响应");
2027
- const { content, reasoning, outputTokens } = streamedResult;
2083
+ const { content, reasoning, outputTokens, cacheHitPercent } = streamedResult;
2028
2084
  const processSteps = reasoning.trim()
2029
2085
  ? [{ id: thinkingStepId, type: "thinking", round: 1, content: reasoning, createdAt: thinkingCreatedAt }]
2030
2086
  : [];
@@ -2038,7 +2094,7 @@ export class AiManager {
2038
2094
  outputChars: content.length,
2039
2095
  outputTokens
2040
2096
  });
2041
- return { callId, content, outputTokens, provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: [], processSteps };
2097
+ return { callId, content, outputTokens, ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }), provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: [], processSteps };
2042
2098
  }
2043
2099
  catch (error) {
2044
2100
  const message = error instanceof Error ? error.message : "AI 流式调用失败";
@@ -2105,7 +2161,8 @@ export class AiManager {
2105
2161
  consumeEvent(buffer);
2106
2162
  if (!content.trim())
2107
2163
  throw new Error(`Chat Completions 流式响应缺少可用正文,finish_reason=${finishReason}`);
2108
- return { content, reasoning, outputTokens: resolveOutputTokens(usage, content) };
2164
+ const cacheHitPercent = resolveCacheHitPercent(usage);
2165
+ return { content, reasoning, outputTokens: resolveOutputTokens(usage, content), ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }) };
2109
2166
  }
2110
2167
  async runChapterAnalysis(workId, scope, modelId, taskId) {
2111
2168
  if (!scope.chapterId)
@@ -2991,6 +3048,17 @@ export class AiManager {
2991
3048
  const characters = this.store.listCharacters(workId);
2992
3049
  if (characters.length < 2)
2993
3050
  throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
3051
+ const selectedCharacterIds = new Set(scope.characterIds ?? []);
3052
+ for (const characterId of selectedCharacterIds) {
3053
+ const character = characters.find((item) => item.id === characterId);
3054
+ if (!character)
3055
+ throw new AppError(400, "CHARACTER_WORK_MISMATCH", "被分析角色不属于当前作品");
3056
+ }
3057
+ const targeted = selectedCharacterIds.size > 0;
3058
+ const targetedRoster = characters
3059
+ .filter((character) => selectedCharacterIds.has(String(character.id)))
3060
+ .map((character) => `${String(character.id)} | ${String(character.name)}`)
3061
+ .join("\n");
2994
3062
  const chapters = this.getScopeChapters(workId, scope);
2995
3063
  if (chapters.length === 0)
2996
3064
  throw new AppError(409, "CHAPTERS_REQUIRED", "人物关系分析范围内没有章节");
@@ -3008,10 +3076,28 @@ export class AiManager {
3008
3076
  taskType: "relationship-analysis",
3009
3077
  signal: this.taskSignal(taskId),
3010
3078
  maxAttempts,
3011
- scope: { type: "selection", selection: text },
3079
+ scope: {
3080
+ type: "selection",
3081
+ selection: text,
3082
+ includeAllSettings: scope.includeAllSettings,
3083
+ ...(targeted ? { characterIds: [...selectedCharacterIds], excludeRelationshipConstraints: scope.replaceExistingRelationships === true } : {})
3084
+ },
3012
3085
  ...(modelId ? { modelId } : {}),
3013
3086
  parameters: { temperature: 0.1 },
3014
- instruction: [
3087
+ instruction: targeted ? [
3088
+ "你是定向人物关系证据收集器。本阶段只建立跨章节证据账本,不下最终关系结论。",
3089
+ "被分析角色:",
3090
+ targetedRoster,
3091
+ "完整角色规范表:",
3092
+ roster,
3093
+ "规则:",
3094
+ "1. 只记录与至少一名被分析角色直接有关的互动、称谓、亲缘线索、权力行为、情感变化、冲突、回忆或第三方陈述。",
3095
+ "2. 单次见面、同场出现和含糊代词可以作为待汇总线索,但必须如实描述,不能在本阶段升级为长期关系。",
3096
+ "3. 人物引用优先填写规范表中的 characterId;暂时不能确定对方身份时填写 relatedReference,禁止创造角色。",
3097
+ "4. 每条线索只引用一个连续原文短句,quote 不超过 80 字,并准确提供 chapterId、chapterTitle 和 contextType。",
3098
+ "5. 输出 JSON 数组。字段:targetCharacterId、relatedCharacterId、relatedReference、observation、possibleCategory、possibleSubtype、directionHint、timeHint、chapterId、chapterTitle、quote、contextType。",
3099
+ "6. 没有与目标角色直接相关的线索时输出 []。"
3100
+ ].join("\n") : [
3015
3101
  "你是小说人物关系抽取器,不是续写者。只抽取角色规范表中人物之间、对跨章节人物图有长期意义且有原文证据的关系。",
3016
3102
  "角色规范表:",
3017
3103
  roster,
@@ -3042,7 +3128,12 @@ export class AiManager {
3042
3128
  "23. 输出 JSON 数组。字段:fromCharacterId、toCharacterId、category(family/social/emotional/conflict/uncertain)、subtype、keywords、directed、currentStatus、timeRange、confidence、evidence。",
3043
3129
  "24. 共同执行一次任务、同属一个组织、在同一集体场景中被感谢或落泪、替第三人转发消息,都不能单独证明同事、朋友或盟友。此类关系必须有原文明示身份,或至少两个不同章节的持续互动证据。"
3044
3130
  ].join("\n"),
3045
- extraSystemPrompt: "关系候选必须可审计。严禁把梦境伴侣、醉后梦话、单次约定、同章共现、礼称、同族归属、救援照护或类比提及写成现实长期关系。逐句校验说话人和关系方向。"
3131
+ extraSystemPrompt: [
3132
+ targeted
3133
+ ? "你正在为指定角色收集可审计的跨章节关系线索。不得在证据收集阶段把单次互动直接判定为长期关系。"
3134
+ : "关系候选必须可审计。严禁把梦境伴侣、醉后梦话、单次约定、同章共现、礼称、同族归属、救援照护或类比提及写成现实长期关系。逐句校验说话人和关系方向。",
3135
+ scope.additionalPrompt?.trim() ? `作者追加的关系分析提示:\n${scope.additionalPrompt.trim()}` : ""
3136
+ ].filter(Boolean).join("\n\n")
3046
3137
  });
3047
3138
  const extracted = extractJson(generated.content);
3048
3139
  if (!Array.isArray(extracted))
@@ -3066,7 +3157,8 @@ export class AiManager {
3066
3157
  }
3067
3158
  }, (completed) => {
3068
3159
  if (taskId && this.store.getTask(taskId).status === "running") {
3069
- this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 5 + Math.round(completed / chunks.length * 87)) });
3160
+ const maximumProgress = targeted ? 72 : 92;
3161
+ this.store.updateTask(taskId, { status: "running", progress: Math.min(maximumProgress, 5 + Math.round(completed / chunks.length * (maximumProgress - 5))) });
3070
3162
  }
3071
3163
  });
3072
3164
  let fallbackSegmentCount = 0;
@@ -3087,6 +3179,87 @@ export class AiManager {
3087
3179
  batchCount: chunks.length
3088
3180
  });
3089
3181
  }
3182
+ const targetedEvidenceCount = targeted ? rawCandidates.length : 0;
3183
+ let aggregationBatchCount = 0;
3184
+ if (targeted && rawCandidates.length > 0) {
3185
+ const evidenceGroups = new Map();
3186
+ for (const evidence of rawCandidates) {
3187
+ const target = String(evidence.targetCharacterId ?? "");
3188
+ const related = String(evidence.relatedCharacterId ?? evidence.relatedReference ?? "unknown");
3189
+ const key = `${target}|${related}`;
3190
+ const group = evidenceGroups.get(key) ?? [];
3191
+ group.push(evidence);
3192
+ evidenceGroups.set(key, group);
3193
+ }
3194
+ const evidenceBatches = [];
3195
+ let currentBatch = [];
3196
+ let currentLength = 0;
3197
+ for (const group of evidenceGroups.values()) {
3198
+ const groupLength = JSON.stringify(group).length;
3199
+ if (currentBatch.length > 0 && currentLength + groupLength > 60_000) {
3200
+ evidenceBatches.push(currentBatch);
3201
+ currentBatch = [];
3202
+ currentLength = 0;
3203
+ }
3204
+ currentBatch.push(...group);
3205
+ currentLength += groupLength;
3206
+ }
3207
+ if (currentBatch.length > 0)
3208
+ evidenceBatches.push(currentBatch);
3209
+ aggregationBatchCount = evidenceBatches.length;
3210
+ const aggregationResults = await this.processChunks(evidenceBatches, Math.min(concurrency, 4), async (evidenceBatch) => {
3211
+ const generated = await this.generateTaggedJson({
3212
+ workId,
3213
+ taskType: "relationship-analysis",
3214
+ signal: this.taskSignal(taskId),
3215
+ maxAttempts: 2,
3216
+ scope: {
3217
+ type: "entities",
3218
+ includeAllSettings: scope.includeAllSettings,
3219
+ characterIds: [...selectedCharacterIds],
3220
+ excludeRelationshipConstraints: scope.replaceExistingRelationships === true
3221
+ },
3222
+ ...(modelId ? { modelId } : {}),
3223
+ parameters: { temperature: 0.1 },
3224
+ instruction: [
3225
+ "你是小说人物关系全局归纳器。请综合分析范围内为指定角色收集的全部跨章节证据线索,形成最终长期关系候选。",
3226
+ "被分析角色:",
3227
+ targetedRoster,
3228
+ "完整角色规范表:",
3229
+ roster,
3230
+ "证据账本:",
3231
+ JSON.stringify(evidenceBatch),
3232
+ "归纳规则:",
3233
+ "1. 只输出至少一端属于被分析角色的关系,另一端也必须解析为角色规范表中的 characterId。",
3234
+ "2. 综合不同章节、不同阶段和设定信息判断关系;设定只用于身份消歧和辅助理解,不能代替章节原文证据。",
3235
+ "3. 单次见面、同场出现、一次任务协作、同组织或同族不能单独升级为长期朋友、同事、盟友、君臣或亲属。",
3236
+ "4. evidence 只能使用证据账本中的连续原文 quote,必须包含 chapterId、chapterTitle、quote、contextType、supports;quote 不超过 80 字。",
3237
+ "5. category 只能是 family、social、emotional、conflict、uncertain;confidence 低于 0.6 不输出。",
3238
+ "6. subtype 使用稳定简短中文词;父母子女、君臣、师生、倾慕、施害与受害等有方向关系必须正确设置 from、to 和 directed=true。",
3239
+ "7. 同一人物对的阶段变化合并进 timeRange.stages;同一 category/subtype 不得输出反向重复边。",
3240
+ "8. keywords 提供 2 至 8 个描述双方互动、权力结构、情感阶段或剧情张力的中文关键词。",
3241
+ "9. 输出 JSON 数组。字段:fromCharacterId、toCharacterId、category、subtype、keywords、directed、currentStatus、timeRange、confidence、evidence。"
3242
+ ].join("\n"),
3243
+ extraSystemPrompt: [
3244
+ "你正在执行指定角色的跨章节关系归纳。所有结论必须能回溯到证据账本中的章节原文,不得沿用缺乏本次证据的旧关系。",
3245
+ scope.additionalPrompt?.trim() ? `作者追加的关系分析提示:\n${scope.additionalPrompt.trim()}` : ""
3246
+ ].filter(Boolean).join("\n\n")
3247
+ });
3248
+ const extracted = extractJson(generated.content);
3249
+ if (!Array.isArray(extracted))
3250
+ throw new AppError(502, "AI_INVALID_JSON", "定向人物关系归纳结果必须是数组");
3251
+ return {
3252
+ candidates: extracted.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)),
3253
+ callId: generated.callId
3254
+ };
3255
+ }, (completed) => {
3256
+ if (taskId && this.store.getTask(taskId).status === "running") {
3257
+ this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 72 + Math.round(completed / evidenceBatches.length * 20)) });
3258
+ }
3259
+ });
3260
+ rawCandidates.splice(0, rawCandidates.length, ...aggregationResults.flatMap((result) => result.candidates));
3261
+ callIds.push(...aggregationResults.map((result) => result.callId));
3262
+ }
3090
3263
  const chapterById = new Map(chapters.map((chapter) => [String(chapter.id), chapter]));
3091
3264
  const categories = new Set(["family", "social", "emotional", "conflict", "uncertain"]);
3092
3265
  const merged = new Map();
@@ -3104,6 +3277,10 @@ export class AiManager {
3104
3277
  skipped.push({ index, reason: "人物引用无效" });
3105
3278
  return;
3106
3279
  }
3280
+ if (targeted && !selectedCharacterIds.has(fromResolved) && !selectedCharacterIds.has(toResolved)) {
3281
+ skipped.push({ index, reason: "关系不涉及本次选定角色" });
3282
+ return;
3283
+ }
3107
3284
  if (typeof candidate.category !== "string" || !categories.has(candidate.category)) {
3108
3285
  skipped.push({ index, reason: "关系分类无效" });
3109
3286
  return;
@@ -3213,10 +3390,17 @@ export class AiManager {
3213
3390
  merged.delete(key);
3214
3391
  }
3215
3392
  const relationshipIds = [];
3393
+ let replacedRelationshipCount = 0;
3216
3394
  this.store.db.transaction(() => {
3217
- if (scope.type === "book") {
3395
+ if (!targeted && scope.type === "book") {
3218
3396
  this.store.db.run("DELETE FROM relationships WHERE work_id = ? AND confirmation_status = 'pending' AND locked = 0", workId);
3219
3397
  }
3398
+ if (targeted && scope.replaceExistingRelationships === true) {
3399
+ const relationshipsToReplace = this.store.listRelationships(workId).filter((relationship) => selectedCharacterIds.has(String(relationship.fromCharacterId)) || selectedCharacterIds.has(String(relationship.toCharacterId)));
3400
+ for (const relationship of relationshipsToReplace)
3401
+ this.store.deleteRelationship(String(relationship.id));
3402
+ replacedRelationshipCount = relationshipsToReplace.length;
3403
+ }
3220
3404
  const existing = this.store.listRelationships(workId).filter((relationship) => relationship.confirmationStatus !== "rejected");
3221
3405
  const unorderedPairKey = (fromCharacterId, toCharacterId) => {
3222
3406
  const pair = [String(fromCharacterId), String(toCharacterId)].sort((left, right) => left.localeCompare(right));
@@ -3413,7 +3597,11 @@ export class AiManager {
3413
3597
  skippedCount: skipped.length,
3414
3598
  fallbackSegmentCount,
3415
3599
  policyOmittedSegmentCount,
3416
- scopeType: scope.type
3600
+ scopeType: scope.type,
3601
+ targetedCharacterCount: selectedCharacterIds.size,
3602
+ targetedEvidenceCount,
3603
+ aggregationBatchCount,
3604
+ replacedRelationshipCount
3417
3605
  });
3418
3606
  return {
3419
3607
  relationshipIds,
@@ -3424,6 +3612,10 @@ export class AiManager {
3424
3612
  coveredChapterCount: chapters.length,
3425
3613
  fallbackSegmentCount,
3426
3614
  policyOmittedSegmentCount,
3615
+ targetedCharacterIds: [...selectedCharacterIds],
3616
+ targetedEvidenceCount,
3617
+ aggregationBatchCount,
3618
+ replacedRelationshipCount,
3427
3619
  callIds
3428
3620
  };
3429
3621
  }