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