@musnows/scriverse 0.5.2 → 0.5.4
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/LICENSE +661 -21
- package/README.en.md +8 -0
- package/README.md +12 -3
- package/dist/ai-protocol.js +216 -0
- package/dist/ai-protocol.js.map +1 -0
- package/dist/ai.js +1082 -187
- package/dist/ai.js.map +1 -1
- package/dist/app.js +51 -6
- package/dist/app.js.map +1 -1
- package/dist/database.js +339 -0
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +77 -16
- package/dist/public/display-labels.d.ts +1 -0
- package/dist/public/display-labels.js +8 -0
- package/dist/public/index.html +13 -7
- package/dist/public/styles.css +17 -0
- package/dist/relationship-search.js +196 -0
- package/dist/relationship-search.js.map +1 -0
- package/dist/store.js +59 -8
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +23 -6
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +1 -1
- package/dist/utils.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
package/dist/ai.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { buildCompletionRequestBody, normalizeProviderBaseUrl, parseCompletionPayload, providerCompletionEndpoint, providerModelEndpoints, providerRequestHeaders } from "./ai-protocol.js";
|
|
1
2
|
import { PLATFORM_AI_WORK_ID } from "./database.js";
|
|
2
3
|
import { AppError, notFound } from "./errors.js";
|
|
3
4
|
import { logger, sanitizeError } from "./logger.js";
|
|
4
5
|
import { paginated, paginationSql } from "./pagination.js";
|
|
5
6
|
import { currentRequestActor } from "./request-context.js";
|
|
6
7
|
import { fetchSafeAiEndpoint } from "./security.js";
|
|
7
|
-
import {
|
|
8
|
+
import { RELATIONSHIP_SEARCH_POLICY_VERSION, RelationshipApproximateMatchLimitError, findApproximateNameMatchesChunked, ftsPhrase, normalizeRelationshipSearchText, relationshipCharacterTokenText, relationshipCharacterTokens, relationshipPinyinTokenText, relationshipPinyinTokens } from "./relationship-search.js";
|
|
9
|
+
import { clamp, id, json, maskSecret, now } from "./utils.js";
|
|
8
10
|
import { z } from "zod";
|
|
9
11
|
export function aiErrorForLog(error) {
|
|
10
12
|
const sanitized = sanitizeError(error);
|
|
@@ -19,6 +21,11 @@ export function aiErrorForLog(error) {
|
|
|
19
21
|
const allowedParameters = new Set(["temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty", "seed"]);
|
|
20
22
|
const DEFAULT_MAX_TOKENS = 32_000;
|
|
21
23
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
24
|
+
const RELATIONSHIP_MAX_FUZZY_REFERENCES = 32;
|
|
25
|
+
const RELATIONSHIP_MAX_FUZZY_SOURCES = 200;
|
|
26
|
+
const RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS = 4_000_000;
|
|
27
|
+
const RELATIONSHIP_MAX_FUZZY_MATCHES = 600;
|
|
28
|
+
const RELATIONSHIP_MAX_SOURCE_MATCHES = 256;
|
|
22
29
|
function isGeminiProviderOrModel(provider, model) {
|
|
23
30
|
const endpoint = stringValue(provider, "base_url").toLowerCase();
|
|
24
31
|
const modelId = stringValue(model, "model_id").toLowerCase();
|
|
@@ -27,9 +34,22 @@ function isGeminiProviderOrModel(provider, model) {
|
|
|
27
34
|
function isKimiModelId(modelId) {
|
|
28
35
|
return modelId.toLowerCase().includes("kimi");
|
|
29
36
|
}
|
|
37
|
+
function providerProtocol(provider) {
|
|
38
|
+
return stringValue(provider, "protocol") === "anthropic-messages" ? "anthropic-messages" : "openai-chat-completions";
|
|
39
|
+
}
|
|
40
|
+
function isLongCatProvider(provider) {
|
|
41
|
+
try {
|
|
42
|
+
return new URL(stringValue(provider, "base_url")).hostname.toLowerCase() === "api.longcat.chat";
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
30
48
|
function thinkingParameters(provider, model) {
|
|
31
49
|
if (isGeminiProviderOrModel(provider, model))
|
|
32
50
|
return {};
|
|
51
|
+
if (providerProtocol(provider) === "anthropic-messages" && !isLongCatProvider(provider))
|
|
52
|
+
return {};
|
|
33
53
|
return { thinking: { type: boolValue(model, "thinking_enabled") ? "enabled" : "disabled" } };
|
|
34
54
|
}
|
|
35
55
|
const AGENT_TOOL_IDS = ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"];
|
|
@@ -850,18 +870,30 @@ export class AiManager {
|
|
|
850
870
|
vault;
|
|
851
871
|
fetchImpl;
|
|
852
872
|
validateOutboundUrl;
|
|
873
|
+
authorizeTaskRun;
|
|
853
874
|
contextBuilder;
|
|
854
875
|
taskControllers = new Map();
|
|
855
876
|
autoRunBatches = new Map();
|
|
856
877
|
autoRunTimers = new Map();
|
|
878
|
+
relationshipIndexBuilds = new Map();
|
|
879
|
+
relationshipSelectionCache = new Map();
|
|
880
|
+
relationshipSelectionBuilds = new Map();
|
|
881
|
+
relationshipIndexSerial = Promise.resolve();
|
|
882
|
+
relationshipIndexTimer = null;
|
|
883
|
+
relationshipIndexDisposed = false;
|
|
857
884
|
providerSchedules = new Map();
|
|
858
|
-
constructor(store, vault, fetchImpl = fetch, validateOutboundUrl) {
|
|
885
|
+
constructor(store, vault, fetchImpl = fetch, validateOutboundUrl, authorizeTaskRun) {
|
|
859
886
|
this.store = store;
|
|
860
887
|
this.vault = vault;
|
|
861
888
|
this.fetchImpl = fetchImpl;
|
|
862
889
|
this.validateOutboundUrl = validateOutboundUrl;
|
|
890
|
+
this.authorizeTaskRun = authorizeTaskRun;
|
|
863
891
|
this.contextBuilder = new ContextBuilder(store);
|
|
864
892
|
this.store.setAnalysisTaskQueuedHandler((workId) => this.scheduleAutoRun(workId));
|
|
893
|
+
this.relationshipIndexTimer = setTimeout(() => {
|
|
894
|
+
this.relationshipIndexTimer = null;
|
|
895
|
+
void this.schedulePendingRelationshipIndexes();
|
|
896
|
+
}, 0);
|
|
865
897
|
logger.info("ai.manager.ready");
|
|
866
898
|
}
|
|
867
899
|
resetAutoRunBatch(workId) {
|
|
@@ -913,6 +945,10 @@ export class AiManager {
|
|
|
913
945
|
clearTimeout(timer);
|
|
914
946
|
this.autoRunTimers.clear();
|
|
915
947
|
this.autoRunBatches.clear();
|
|
948
|
+
this.relationshipIndexDisposed = true;
|
|
949
|
+
if (this.relationshipIndexTimer)
|
|
950
|
+
clearTimeout(this.relationshipIndexTimer);
|
|
951
|
+
this.relationshipIndexTimer = null;
|
|
916
952
|
this.store.setAnalysisTaskQueuedHandler(null);
|
|
917
953
|
logger.info("ai.manager.disposed");
|
|
918
954
|
}
|
|
@@ -968,10 +1004,12 @@ export class AiManager {
|
|
|
968
1004
|
const providerId = id("provider");
|
|
969
1005
|
const encrypted = this.vault.encrypt(input.apiKey);
|
|
970
1006
|
const timestamp = now();
|
|
971
|
-
|
|
1007
|
+
const protocol = input.protocol ?? "openai-chat-completions";
|
|
1008
|
+
const baseUrl = normalizeProviderBaseUrl(input.baseUrl);
|
|
1009
|
+
this.store.db.run(`INSERT INTO providers (id, work_id, name, base_url, protocol, encrypted_key, key_iv, key_tag, key_hint, status,
|
|
972
1010
|
connection_status, concurrency_limit, rpm_limit, max_tokens, note, created_at, updated_at)
|
|
973
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'unchecked', ?, ?, ?, ?, ?, ?)`, providerId, PLATFORM_AI_WORK_ID, input.name,
|
|
974
|
-
this.store.audit(PLATFORM_AI_WORK_ID, "provider.created", "provider", providerId, { name: input.name, baseUrl
|
|
1011
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unchecked', ?, ?, ?, ?, ?, ?)`, providerId, PLATFORM_AI_WORK_ID, input.name, baseUrl, protocol, encrypted.encrypted, encrypted.iv, encrypted.tag, maskSecret(input.apiKey), input.status ?? "disabled", input.concurrencyLimit ?? 10, input.rpmLimit ?? 10, input.maxTokens ?? DEFAULT_MAX_TOKENS, input.note ?? "", timestamp, timestamp);
|
|
1012
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "provider.created", "provider", providerId, { name: input.name, baseUrl, protocol });
|
|
975
1013
|
return this.getProvider(providerId);
|
|
976
1014
|
}
|
|
977
1015
|
listProviders() {
|
|
@@ -1000,10 +1038,12 @@ export class AiManager {
|
|
|
1000
1038
|
keyHint = maskSecret(input.apiKey);
|
|
1001
1039
|
connectionStatus = "unchecked";
|
|
1002
1040
|
}
|
|
1003
|
-
if (input.baseUrl &&
|
|
1041
|
+
if (input.baseUrl && normalizeProviderBaseUrl(input.baseUrl) !== stringValue(row, "base_url"))
|
|
1042
|
+
connectionStatus = "unchecked";
|
|
1043
|
+
if (input.protocol && input.protocol !== providerProtocol(row))
|
|
1004
1044
|
connectionStatus = "unchecked";
|
|
1005
|
-
this.store.db.run(`UPDATE providers SET name = ?, base_url = ?, encrypted_key = ?, key_iv = ?, key_tag = ?, key_hint = ?,
|
|
1006
|
-
status = ?, connection_status = ?, concurrency_limit = ?, rpm_limit = ?, max_tokens = ?, note = ?, updated_at = ? WHERE id = ?`, input.name ?? stringValue(row, "name"), input.baseUrl ?
|
|
1045
|
+
this.store.db.run(`UPDATE providers SET name = ?, base_url = ?, protocol = ?, encrypted_key = ?, key_iv = ?, key_tag = ?, key_hint = ?,
|
|
1046
|
+
status = ?, connection_status = ?, concurrency_limit = ?, rpm_limit = ?, max_tokens = ?, note = ?, updated_at = ? WHERE id = ?`, input.name ?? stringValue(row, "name"), input.baseUrl ? normalizeProviderBaseUrl(input.baseUrl) : stringValue(row, "base_url"), input.protocol ?? providerProtocol(row), encryptedKey, keyIv, keyTag, keyHint, input.status ?? stringValue(row, "status"), connectionStatus, input.concurrencyLimit ?? numberValue(row, "concurrency_limit"), input.rpmLimit ?? numberValue(row, "rpm_limit"), input.maxTokens ?? numberValue(row, "max_tokens"), input.note ?? stringValue(row, "note"), now(), providerId);
|
|
1007
1047
|
this.store.audit(PLATFORM_AI_WORK_ID, "provider.updated", "provider", providerId, {
|
|
1008
1048
|
fields: Object.keys(input).filter((key) => key !== "apiKey"),
|
|
1009
1049
|
keyReplaced: Boolean(input.apiKey)
|
|
@@ -1029,26 +1069,40 @@ export class AiManager {
|
|
|
1029
1069
|
async testProvider(providerId) {
|
|
1030
1070
|
const row = this.getProviderRow(providerId);
|
|
1031
1071
|
const apiKey = this.decryptKey(row);
|
|
1072
|
+
const protocol = providerProtocol(row);
|
|
1032
1073
|
const controller = new AbortController();
|
|
1033
1074
|
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
1034
1075
|
const startedAt = process.hrtime.bigint();
|
|
1035
1076
|
logger.info("ai.provider_test.started", { providerId });
|
|
1036
1077
|
try {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1078
|
+
let payload = null;
|
|
1079
|
+
let lastFailure = "AI 供应商没有返回模型列表";
|
|
1080
|
+
const endpoints = providerModelEndpoints(stringValue(row, "base_url"), protocol);
|
|
1081
|
+
for (let index = 0; index < endpoints.length; index += 1) {
|
|
1082
|
+
const endpoint = endpoints[index];
|
|
1083
|
+
if (!endpoint)
|
|
1084
|
+
continue;
|
|
1085
|
+
const response = await this.outboundFetch(endpoint, {
|
|
1086
|
+
headers: providerRequestHeaders(protocol, apiKey, "application/json"),
|
|
1087
|
+
signal: controller.signal
|
|
1088
|
+
});
|
|
1089
|
+
if (response.ok) {
|
|
1090
|
+
payload = (await response.json());
|
|
1091
|
+
break;
|
|
1092
|
+
}
|
|
1043
1093
|
const message = await response.text();
|
|
1044
|
-
|
|
1094
|
+
lastFailure = `HTTP ${response.status}: ${message.slice(0, 300)}`;
|
|
1095
|
+
if (response.status !== 404 || index === endpoints.length - 1)
|
|
1096
|
+
break;
|
|
1045
1097
|
}
|
|
1046
|
-
|
|
1098
|
+
if (!payload)
|
|
1099
|
+
throw new Error(lastFailure);
|
|
1047
1100
|
const availableModels = Array.isArray(payload.data) ? payload.data.map((item) => item.id).filter(Boolean) : [];
|
|
1048
1101
|
const timestamp = now();
|
|
1049
1102
|
this.store.db.run("UPDATE providers SET connection_status = 'success', last_error = NULL, last_success_at = ?, updated_at = ? WHERE id = ?", timestamp, timestamp, providerId);
|
|
1050
1103
|
logger.info("ai.provider_test.completed", {
|
|
1051
1104
|
providerId,
|
|
1105
|
+
protocol,
|
|
1052
1106
|
ok: true,
|
|
1053
1107
|
availableModelCount: availableModels.length,
|
|
1054
1108
|
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000
|
|
@@ -1056,10 +1110,11 @@ export class AiManager {
|
|
|
1056
1110
|
return { ok: true, availableModels, provider: this.getProvider(providerId) };
|
|
1057
1111
|
}
|
|
1058
1112
|
catch (error) {
|
|
1059
|
-
const message = error instanceof Error ? error.message : "连接失败";
|
|
1113
|
+
const message = error instanceof Error ? redactProviderSecret(error.message, apiKey) : "连接失败";
|
|
1060
1114
|
this.store.db.run("UPDATE providers SET connection_status = 'failed', last_error = ?, updated_at = ? WHERE id = ?", message, now(), providerId);
|
|
1061
1115
|
logger.warn("ai.provider_test.completed", {
|
|
1062
1116
|
providerId,
|
|
1117
|
+
protocol,
|
|
1063
1118
|
ok: false,
|
|
1064
1119
|
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
|
|
1065
1120
|
error: aiErrorForLog(error)
|
|
@@ -1091,14 +1146,27 @@ export class AiManager {
|
|
|
1091
1146
|
}
|
|
1092
1147
|
listPlatformModels() {
|
|
1093
1148
|
return this.store.db
|
|
1094
|
-
.all(
|
|
1095
|
-
|
|
1149
|
+
.all(`SELECT m.*, p.name AS provider_name, p.status AS provider_status, p.connection_status AS provider_connection_status
|
|
1150
|
+
FROM models m JOIN providers p ON p.id = m.provider_id
|
|
1151
|
+
WHERE p.work_id = ? ORDER BY p.created_at, m.created_at`, PLATFORM_AI_WORK_ID)
|
|
1152
|
+
.map((row) => ({
|
|
1153
|
+
...this.mapModel(row),
|
|
1154
|
+
providerName: stringValue(row, "provider_name"),
|
|
1155
|
+
providerStatus: stringValue(row, "provider_status"),
|
|
1156
|
+
providerConnectionStatus: stringValue(row, "provider_connection_status")
|
|
1157
|
+
}));
|
|
1096
1158
|
}
|
|
1097
1159
|
listPlatformModelsPage(pagination) {
|
|
1098
1160
|
const page = paginationSql(pagination);
|
|
1099
|
-
const rows = this.store.db.all(`SELECT m.*, p.name AS provider_name
|
|
1161
|
+
const rows = this.store.db.all(`SELECT m.*, p.name AS provider_name, p.status AS provider_status, p.connection_status AS provider_connection_status
|
|
1162
|
+
FROM models m JOIN providers p ON p.id = m.provider_id
|
|
1100
1163
|
WHERE p.work_id = ? ORDER BY p.created_at, m.created_at${page.sql}`, PLATFORM_AI_WORK_ID, ...page.params);
|
|
1101
|
-
return paginated(rows.map((row) => ({
|
|
1164
|
+
return paginated(rows.map((row) => ({
|
|
1165
|
+
...this.mapModel(row),
|
|
1166
|
+
providerName: stringValue(row, "provider_name"),
|
|
1167
|
+
providerStatus: stringValue(row, "provider_status"),
|
|
1168
|
+
providerConnectionStatus: stringValue(row, "provider_connection_status")
|
|
1169
|
+
})), pagination);
|
|
1102
1170
|
}
|
|
1103
1171
|
listWorkModels(workId) {
|
|
1104
1172
|
this.store.getWork(workId);
|
|
@@ -1150,6 +1218,19 @@ export class AiManager {
|
|
|
1150
1218
|
model: this.getModel(stringValue(row, "model_id"))
|
|
1151
1219
|
})), pagination);
|
|
1152
1220
|
}
|
|
1221
|
+
createTask(workId, input) {
|
|
1222
|
+
this.store.getWork(workId);
|
|
1223
|
+
const modelPurpose = this.analysisTaskModelPurpose(input.taskType);
|
|
1224
|
+
const defaultRow = this.store.db.get("SELECT model_id FROM task_defaults WHERE work_id = ? AND task_type = ?", workId, modelPurpose);
|
|
1225
|
+
const modelId = input.modelId ?? (defaultRow ? stringValue(defaultRow, "model_id") : undefined);
|
|
1226
|
+
if (modelId)
|
|
1227
|
+
this.resolveModel(workId, modelPurpose, modelId);
|
|
1228
|
+
return this.store.createTask(workId, {
|
|
1229
|
+
taskType: input.taskType,
|
|
1230
|
+
...(input.scope ? { scope: input.scope } : {}),
|
|
1231
|
+
...(modelId ? { modelId } : {})
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1153
1234
|
async createSuggestion(input) {
|
|
1154
1235
|
const action = input.taskType === "continue" ? "append" : input.taskType === "polish" ? "replace-selection" : "note";
|
|
1155
1236
|
if (action === "replace-selection" && !input.scope.selection) {
|
|
@@ -1444,12 +1525,17 @@ export class AiManager {
|
|
|
1444
1525
|
}
|
|
1445
1526
|
};
|
|
1446
1527
|
}
|
|
1447
|
-
async runTask(taskId, modelId) {
|
|
1528
|
+
async runTask(taskId, modelId, actor) {
|
|
1448
1529
|
const task = this.store.getTask(taskId);
|
|
1530
|
+
this.authorizeTaskRun?.(task, actor);
|
|
1449
1531
|
const workId = String(task.workId);
|
|
1532
|
+
const taskModel = task.model && typeof task.model === "object" && !Array.isArray(task.model)
|
|
1533
|
+
? task.model
|
|
1534
|
+
: null;
|
|
1535
|
+
const selectedModelId = modelId ?? (typeof taskModel?.id === "string" ? taskModel.id : undefined);
|
|
1450
1536
|
const batch = this.getAutoRunBatch(workId);
|
|
1451
1537
|
const startedAt = process.hrtime.bigint();
|
|
1452
|
-
logger.info("ai.task.started", { taskId, workId, taskType: task.taskType, modelId:
|
|
1538
|
+
logger.info("ai.task.started", { taskId, workId, taskType: task.taskType, modelId: selectedModelId ?? null });
|
|
1453
1539
|
if (task.status !== "pending")
|
|
1454
1540
|
throw new AppError(409, "TASK_NOT_PENDING", "只有待执行任务可以运行");
|
|
1455
1541
|
if (!this.store.isTaskSourceCurrent(taskId)) {
|
|
@@ -1472,28 +1558,28 @@ export class AiManager {
|
|
|
1472
1558
|
const scope = task.scope;
|
|
1473
1559
|
let result;
|
|
1474
1560
|
if (taskType === "chapter-analysis") {
|
|
1475
|
-
result = await this.runChapterAnalysis(workId, scope,
|
|
1561
|
+
result = await this.runChapterAnalysis(workId, scope, selectedModelId, taskId);
|
|
1476
1562
|
}
|
|
1477
1563
|
else if (taskType === "character-extraction" || taskType === "character-summary") {
|
|
1478
|
-
result = await this.runCharacterExtraction(workId, scope,
|
|
1564
|
+
result = await this.runCharacterExtraction(workId, scope, selectedModelId, taskId);
|
|
1479
1565
|
}
|
|
1480
1566
|
else if (taskType === "character-identity-audit") {
|
|
1481
|
-
result = await this.runCharacterIdentityAudit(workId, scope,
|
|
1567
|
+
result = await this.runCharacterIdentityAudit(workId, scope, selectedModelId, taskId);
|
|
1482
1568
|
}
|
|
1483
1569
|
else if (taskType === "timeline-analysis") {
|
|
1484
|
-
result = await this.runTimelineAnalysis(workId, scope,
|
|
1570
|
+
result = await this.runTimelineAnalysis(workId, scope, selectedModelId, taskId);
|
|
1485
1571
|
}
|
|
1486
1572
|
else if (taskType === "relationship-analysis") {
|
|
1487
|
-
result = await this.runRelationshipAnalysis(workId, scope,
|
|
1573
|
+
result = await this.runRelationshipAnalysis(workId, scope, selectedModelId, taskId);
|
|
1488
1574
|
}
|
|
1489
1575
|
else if (taskType === "worldview-analysis") {
|
|
1490
|
-
result = await this.runWorldviewAnalysis(workId, scope,
|
|
1576
|
+
result = await this.runWorldviewAnalysis(workId, scope, selectedModelId, taskId);
|
|
1491
1577
|
}
|
|
1492
1578
|
else if (taskType === "setting-extraction") {
|
|
1493
|
-
result = await this.runSettingExtraction(workId, scope,
|
|
1579
|
+
result = await this.runSettingExtraction(workId, scope, selectedModelId, taskId);
|
|
1494
1580
|
}
|
|
1495
1581
|
else if (taskType === "consistency-check") {
|
|
1496
|
-
result = await this.runConsistencyCheck(workId, scope,
|
|
1582
|
+
result = await this.runConsistencyCheck(workId, scope, selectedModelId, taskId);
|
|
1497
1583
|
}
|
|
1498
1584
|
else {
|
|
1499
1585
|
const generated = await this.generate({
|
|
@@ -1503,7 +1589,7 @@ export class AiManager {
|
|
|
1503
1589
|
instruction: "请基于上下文完成分析,给出有原文依据的中文结论。",
|
|
1504
1590
|
scope,
|
|
1505
1591
|
signal: taskController.signal,
|
|
1506
|
-
...(
|
|
1592
|
+
...(selectedModelId ? { modelId: selectedModelId } : {})
|
|
1507
1593
|
});
|
|
1508
1594
|
result = { content: generated.content, callId: generated.callId };
|
|
1509
1595
|
}
|
|
@@ -1975,12 +2061,14 @@ export class AiManager {
|
|
|
1975
2061
|
this.store.db.run("UPDATE ai_call_traces SET rounds_json = ?, source_refs_json = ?, updated_at = ? WHERE call_id = ?", JSON.stringify(traceRounds), JSON.stringify(taskTraceSourceRefs(messages, traceRounds)), now(), callId);
|
|
1976
2062
|
};
|
|
1977
2063
|
const callStartedAt = process.hrtime.bigint();
|
|
2064
|
+
const protocol = providerProtocol(provider);
|
|
1978
2065
|
logger.info("ai.call.started", {
|
|
1979
2066
|
callId,
|
|
1980
2067
|
workId: input.workId,
|
|
1981
2068
|
taskType: input.taskType,
|
|
1982
2069
|
providerId: stringValue(provider, "id"),
|
|
1983
2070
|
modelId: stringValue(model, "id"),
|
|
2071
|
+
protocol,
|
|
1984
2072
|
streaming: false,
|
|
1985
2073
|
contextChars: context.length,
|
|
1986
2074
|
instructionChars: input.instruction.length,
|
|
@@ -1990,7 +2078,7 @@ export class AiManager {
|
|
|
1990
2078
|
try {
|
|
1991
2079
|
const apiKey = this.decryptKey(provider);
|
|
1992
2080
|
activeApiKey = apiKey;
|
|
1993
|
-
const endpoint =
|
|
2081
|
+
const endpoint = providerCompletionEndpoint(stringValue(provider, "base_url"), protocol);
|
|
1994
2082
|
const timeoutMs = input.taskType === "book-analysis" || input.taskType === "relationship-analysis" ? 300_000 : 60_000;
|
|
1995
2083
|
const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
|
|
1996
2084
|
let completionRequestCount = 0;
|
|
@@ -2037,13 +2125,15 @@ export class AiManager {
|
|
|
2037
2125
|
try {
|
|
2038
2126
|
const response = await this.outboundFetch(endpoint, {
|
|
2039
2127
|
method: "POST",
|
|
2040
|
-
headers:
|
|
2041
|
-
body: JSON.stringify({
|
|
2128
|
+
headers: providerRequestHeaders(protocol, apiKey, "application/json"),
|
|
2129
|
+
body: JSON.stringify(buildCompletionRequestBody({
|
|
2130
|
+
protocol,
|
|
2042
2131
|
model: stringValue(model, "model_id"),
|
|
2043
2132
|
messages: completionMessages,
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2133
|
+
parameters,
|
|
2134
|
+
tools,
|
|
2135
|
+
toolChoice
|
|
2136
|
+
})),
|
|
2047
2137
|
signal: controller.signal
|
|
2048
2138
|
});
|
|
2049
2139
|
return { ok: response.ok, status: response.status, body: await response.text() };
|
|
@@ -2062,7 +2152,7 @@ export class AiManager {
|
|
|
2062
2152
|
});
|
|
2063
2153
|
if (candidate.ok) {
|
|
2064
2154
|
try {
|
|
2065
|
-
const parsed = redactProviderSecrets(JSON.parse(candidate.body), apiKey);
|
|
2155
|
+
const parsed = parseCompletionPayload(protocol, redactProviderSecrets(JSON.parse(candidate.body), apiKey));
|
|
2066
2156
|
traceAttempt.completedAt = now();
|
|
2067
2157
|
traceAttempt.status = "completed";
|
|
2068
2158
|
traceAttempt.httpStatus = candidate.status;
|
|
@@ -2079,7 +2169,7 @@ export class AiManager {
|
|
|
2079
2169
|
return parsed;
|
|
2080
2170
|
}
|
|
2081
2171
|
catch {
|
|
2082
|
-
throw new Error(
|
|
2172
|
+
throw new Error(`${protocol === "anthropic-messages" ? "Anthropic Messages" : "Chat Completions"} returned invalid JSON: ${candidate.body.slice(0, 500)}`);
|
|
2083
2173
|
}
|
|
2084
2174
|
}
|
|
2085
2175
|
lastFailure = new Error(`HTTP ${candidate.status}: ${candidate.body.slice(0, 500)}`);
|
|
@@ -2158,7 +2248,8 @@ export class AiManager {
|
|
|
2158
2248
|
role: "assistant",
|
|
2159
2249
|
content: choice.message.content ?? null,
|
|
2160
2250
|
reasoning_content: choice.message.reasoning_content ?? null,
|
|
2161
|
-
tool_calls: normalizedToolCalls
|
|
2251
|
+
tool_calls: normalizedToolCalls,
|
|
2252
|
+
...(choice.message.anthropic_content?.length ? { anthropic_content: choice.message.anthropic_content } : {})
|
|
2162
2253
|
});
|
|
2163
2254
|
for (const toolCall of toolCalls) {
|
|
2164
2255
|
const execution = this.executeAgentTool(input.workId, toolCall);
|
|
@@ -2191,7 +2282,7 @@ export class AiManager {
|
|
|
2191
2282
|
const suffix = choice?.finish_reason === "length" || reasoningLength > 0
|
|
2192
2283
|
? `;模型已生成 ${reasoningLength} 个推理字符,请提高 max_tokens 输出预算`
|
|
2193
2284
|
: "";
|
|
2194
|
-
throw new Error(
|
|
2285
|
+
throw new Error(`${protocol === "anthropic-messages" ? "Anthropic Messages" : "Chat Completions"} 响应缺少可用正文,finish_reason=${choice?.finish_reason ?? "unknown"}${suffix}`);
|
|
2195
2286
|
}
|
|
2196
2287
|
this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
|
|
2197
2288
|
const outputTokens = resolveOutputTokens(payload.usage, content);
|
|
@@ -2237,19 +2328,23 @@ export class AiManager {
|
|
|
2237
2328
|
this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
|
|
2238
2329
|
status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(parameters), context.length + input.instruction.length, now(), currentRequestActor()?.userId ?? null);
|
|
2239
2330
|
const callStartedAt = process.hrtime.bigint();
|
|
2331
|
+
const protocol = providerProtocol(provider);
|
|
2240
2332
|
logger.info("ai.call.started", {
|
|
2241
2333
|
callId,
|
|
2242
2334
|
workId: input.workId,
|
|
2243
2335
|
taskType: input.taskType,
|
|
2244
2336
|
providerId: stringValue(provider, "id"),
|
|
2245
2337
|
modelId: stringValue(model, "id"),
|
|
2338
|
+
protocol,
|
|
2246
2339
|
streaming: true,
|
|
2247
2340
|
contextChars: context.length,
|
|
2248
2341
|
instructionChars: input.instruction.length
|
|
2249
2342
|
});
|
|
2343
|
+
let activeApiKey = "";
|
|
2250
2344
|
try {
|
|
2251
2345
|
const apiKey = this.decryptKey(provider);
|
|
2252
|
-
|
|
2346
|
+
activeApiKey = apiKey;
|
|
2347
|
+
const endpoint = providerCompletionEndpoint(stringValue(provider, "base_url"), protocol);
|
|
2253
2348
|
const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
|
|
2254
2349
|
let streamedResult = null;
|
|
2255
2350
|
let lastFailure = null;
|
|
@@ -2271,13 +2366,19 @@ export class AiManager {
|
|
|
2271
2366
|
try {
|
|
2272
2367
|
const response = await this.outboundFetch(endpoint, {
|
|
2273
2368
|
method: "POST",
|
|
2274
|
-
headers:
|
|
2275
|
-
body: JSON.stringify(
|
|
2369
|
+
headers: providerRequestHeaders(protocol, apiKey, "text/event-stream"),
|
|
2370
|
+
body: JSON.stringify(buildCompletionRequestBody({
|
|
2371
|
+
protocol,
|
|
2372
|
+
model: stringValue(model, "model_id"),
|
|
2373
|
+
messages,
|
|
2374
|
+
parameters,
|
|
2375
|
+
stream: true
|
|
2376
|
+
})),
|
|
2276
2377
|
signal: controller.signal
|
|
2277
2378
|
});
|
|
2278
2379
|
if (!response.ok)
|
|
2279
2380
|
return { ok: false, status: response.status, body: await response.text() };
|
|
2280
|
-
const streamed = await this.readCompletionStream(response, (delta) => {
|
|
2381
|
+
const streamed = await this.readCompletionStream(response, protocol, (delta) => {
|
|
2281
2382
|
emitted = true;
|
|
2282
2383
|
onDelta(delta);
|
|
2283
2384
|
}, (delta) => {
|
|
@@ -2342,7 +2443,7 @@ export class AiManager {
|
|
|
2342
2443
|
return { callId, content, outputTokens, ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }), provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: [], processSteps };
|
|
2343
2444
|
}
|
|
2344
2445
|
catch (error) {
|
|
2345
|
-
const message = error instanceof Error ? error.message : "AI 流式调用失败";
|
|
2446
|
+
const message = error instanceof Error ? redactProviderSecret(error.message, activeApiKey) : "AI 流式调用失败";
|
|
2346
2447
|
this.store.db.run("UPDATE ai_calls SET status = 'failed', failure = ?, completed_at = ? WHERE id = ?", message, now(), callId);
|
|
2347
2448
|
logger.error("ai.call.failed", {
|
|
2348
2449
|
callId,
|
|
@@ -2355,9 +2456,10 @@ export class AiManager {
|
|
|
2355
2456
|
throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message });
|
|
2356
2457
|
}
|
|
2357
2458
|
}
|
|
2358
|
-
async readCompletionStream(response, onDelta, onThinkingDelta) {
|
|
2459
|
+
async readCompletionStream(response, protocol, onDelta, onThinkingDelta) {
|
|
2460
|
+
const protocolLabel = protocol === "anthropic-messages" ? "Anthropic Messages" : "Chat Completions";
|
|
2359
2461
|
if (!response.body)
|
|
2360
|
-
throw new Error(
|
|
2462
|
+
throw new Error(`${protocolLabel} 流式响应缺少正文`);
|
|
2361
2463
|
const reader = response.body.getReader();
|
|
2362
2464
|
const decoder = new TextDecoder();
|
|
2363
2465
|
let buffer = "";
|
|
@@ -2374,19 +2476,59 @@ export class AiManager {
|
|
|
2374
2476
|
if (!data || data === "[DONE]")
|
|
2375
2477
|
return;
|
|
2376
2478
|
const payload = JSON.parse(data);
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
if (
|
|
2479
|
+
const error = payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)
|
|
2480
|
+
? payload.error
|
|
2481
|
+
: null;
|
|
2482
|
+
if (error)
|
|
2483
|
+
throw new Error(typeof error.message === "string" ? error.message : "上游流式响应返回错误");
|
|
2484
|
+
if (protocol === "anthropic-messages") {
|
|
2485
|
+
const eventUsage = payload.usage && typeof payload.usage === "object" && !Array.isArray(payload.usage)
|
|
2486
|
+
? payload.usage
|
|
2487
|
+
: null;
|
|
2488
|
+
const message = payload.message && typeof payload.message === "object" && !Array.isArray(payload.message)
|
|
2489
|
+
? payload.message
|
|
2490
|
+
: null;
|
|
2491
|
+
const messageUsage = message?.usage && typeof message.usage === "object" && !Array.isArray(message.usage)
|
|
2492
|
+
? message.usage
|
|
2493
|
+
: null;
|
|
2494
|
+
if (eventUsage || messageUsage) {
|
|
2495
|
+
usage = { ...(usage && typeof usage === "object" ? usage : {}), ...(messageUsage ?? {}), ...(eventUsage ?? {}) };
|
|
2496
|
+
}
|
|
2497
|
+
const eventDelta = payload.delta && typeof payload.delta === "object" && !Array.isArray(payload.delta)
|
|
2498
|
+
? payload.delta
|
|
2499
|
+
: {};
|
|
2500
|
+
if (typeof eventDelta.stop_reason === "string")
|
|
2501
|
+
finishReason = eventDelta.stop_reason;
|
|
2502
|
+
if (eventDelta.type === "thinking_delta" && typeof eventDelta.thinking === "string" && eventDelta.thinking.length > 0) {
|
|
2503
|
+
reasoning += eventDelta.thinking;
|
|
2504
|
+
onThinkingDelta(eventDelta.thinking);
|
|
2505
|
+
}
|
|
2506
|
+
if (eventDelta.type === "text_delta" && typeof eventDelta.text === "string" && eventDelta.text.length > 0) {
|
|
2507
|
+
content += eventDelta.text;
|
|
2508
|
+
onDelta(eventDelta.text);
|
|
2509
|
+
}
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
2512
|
+
const streamUsage = payload.usage && typeof payload.usage === "object" && !Array.isArray(payload.usage)
|
|
2513
|
+
? payload.usage
|
|
2514
|
+
: null;
|
|
2515
|
+
if (streamUsage)
|
|
2516
|
+
usage = streamUsage;
|
|
2517
|
+
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
|
2518
|
+
const choice = choices[0] && typeof choices[0] === "object" && !Array.isArray(choices[0])
|
|
2519
|
+
? choices[0]
|
|
2520
|
+
: null;
|
|
2521
|
+
if (typeof choice?.finish_reason === "string")
|
|
2383
2522
|
finishReason = choice.finish_reason;
|
|
2384
|
-
const
|
|
2523
|
+
const deltaRecord = choice?.delta && typeof choice.delta === "object" && !Array.isArray(choice.delta)
|
|
2524
|
+
? choice.delta
|
|
2525
|
+
: {};
|
|
2526
|
+
const thinkingDelta = deltaRecord.reasoning_content;
|
|
2385
2527
|
if (typeof thinkingDelta === "string" && thinkingDelta.length > 0) {
|
|
2386
2528
|
reasoning += thinkingDelta;
|
|
2387
2529
|
onThinkingDelta(thinkingDelta);
|
|
2388
2530
|
}
|
|
2389
|
-
const delta =
|
|
2531
|
+
const delta = deltaRecord.content;
|
|
2390
2532
|
if (typeof delta === "string" && delta.length > 0) {
|
|
2391
2533
|
content += delta;
|
|
2392
2534
|
onDelta(delta);
|
|
@@ -2405,7 +2547,7 @@ export class AiManager {
|
|
|
2405
2547
|
if (buffer.trim())
|
|
2406
2548
|
consumeEvent(buffer);
|
|
2407
2549
|
if (!content.trim())
|
|
2408
|
-
throw new Error(
|
|
2550
|
+
throw new Error(`${protocolLabel} 流式响应缺少可用正文,finish_reason=${finishReason}`);
|
|
2409
2551
|
const cacheHitPercent = resolveCacheHitPercent(usage);
|
|
2410
2552
|
return { content, reasoning, outputTokens: resolveOutputTokens(usage, content), ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }) };
|
|
2411
2553
|
}
|
|
@@ -3297,8 +3439,651 @@ export class AiManager {
|
|
|
3297
3439
|
}
|
|
3298
3440
|
};
|
|
3299
3441
|
}
|
|
3300
|
-
|
|
3301
|
-
|
|
3442
|
+
async schedulePendingRelationshipIndexes() {
|
|
3443
|
+
if (this.relationshipIndexDisposed)
|
|
3444
|
+
return;
|
|
3445
|
+
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
|
+
await Promise.allSettled(workIds.map((workId) => this.ensureRelationshipSearchIndex(workId)));
|
|
3447
|
+
}
|
|
3448
|
+
ensureRelationshipSearchIndex(workId) {
|
|
3449
|
+
const existing = this.relationshipIndexBuilds.get(workId);
|
|
3450
|
+
if (existing)
|
|
3451
|
+
return existing;
|
|
3452
|
+
const build = this.relationshipIndexSerial.then(async () => this.drainRelationshipSearchIndex(workId));
|
|
3453
|
+
this.relationshipIndexSerial = build.then(() => undefined, () => undefined);
|
|
3454
|
+
this.relationshipIndexBuilds.set(workId, build);
|
|
3455
|
+
void build.finally(() => {
|
|
3456
|
+
if (this.relationshipIndexBuilds.get(workId) === build)
|
|
3457
|
+
this.relationshipIndexBuilds.delete(workId);
|
|
3458
|
+
}).catch(() => undefined);
|
|
3459
|
+
return build;
|
|
3460
|
+
}
|
|
3461
|
+
async drainRelationshipSearchIndex(workId) {
|
|
3462
|
+
if (this.relationshipIndexDisposed)
|
|
3463
|
+
return 0;
|
|
3464
|
+
const timestamp = now();
|
|
3465
|
+
this.store.db.run(`INSERT INTO relationship_source_index_state(work_id, status, generation, error, updated_at)
|
|
3466
|
+
VALUES (?, 'building', 0, '', ?)
|
|
3467
|
+
ON CONFLICT(work_id) DO UPDATE SET status = 'building', error = '', updated_at = excluded.updated_at`, workId, timestamp);
|
|
3468
|
+
let processed = 0;
|
|
3469
|
+
try {
|
|
3470
|
+
while (!this.relationshipIndexDisposed) {
|
|
3471
|
+
const queued = this.store.db.all(`SELECT source_type, source_id, queued_at FROM relationship_source_index_queue
|
|
3472
|
+
WHERE work_id = ? ORDER BY queued_at, source_type, source_id LIMIT 50`, workId);
|
|
3473
|
+
if (queued.length === 0)
|
|
3474
|
+
break;
|
|
3475
|
+
for (const item of queued) {
|
|
3476
|
+
const sourceType = String(item.source_type);
|
|
3477
|
+
const sourceId = String(item.source_id);
|
|
3478
|
+
const queuedAt = String(item.queued_at);
|
|
3479
|
+
this.store.db.transaction(() => {
|
|
3480
|
+
if (sourceType === "chapter")
|
|
3481
|
+
this.indexRelationshipChapter(workId, sourceId);
|
|
3482
|
+
else
|
|
3483
|
+
this.indexRelationshipSettingSource(workId, sourceType, sourceId);
|
|
3484
|
+
this.store.db.run(`DELETE FROM relationship_source_index_queue
|
|
3485
|
+
WHERE work_id = ? AND source_type = ? AND source_id = ? AND queued_at = ?`, workId, sourceType, sourceId, queuedAt);
|
|
3486
|
+
});
|
|
3487
|
+
processed += 1;
|
|
3488
|
+
}
|
|
3489
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
3490
|
+
}
|
|
3491
|
+
if (this.relationshipIndexDisposed) {
|
|
3492
|
+
this.store.db.run("UPDATE relationship_source_index_state SET status = 'queued', updated_at = ? WHERE work_id = ?", now(), workId);
|
|
3493
|
+
return 0;
|
|
3494
|
+
}
|
|
3495
|
+
this.store.db.run(`UPDATE relationship_source_index_state
|
|
3496
|
+
SET status = 'ready', generation = generation + ?, error = '', updated_at = ? WHERE work_id = ?`, processed > 0 ? 1 : 0, now(), workId);
|
|
3497
|
+
const generation = Number(this.store.db.get("SELECT generation FROM relationship_source_index_state WHERE work_id = ?", workId)?.generation ?? 0);
|
|
3498
|
+
logger.info("relationship.search_index.ready", { workId, generation, processed });
|
|
3499
|
+
return generation;
|
|
3500
|
+
}
|
|
3501
|
+
catch (error) {
|
|
3502
|
+
const message = error instanceof Error ? error.message : "索引构建失败";
|
|
3503
|
+
this.store.db.run("UPDATE relationship_source_index_state SET status = 'failed', error = ?, updated_at = ? WHERE work_id = ?", message.slice(0, 2_000), now(), workId);
|
|
3504
|
+
logger.error("relationship.search_index.failed", { workId, processed, error: sanitizeError(error) });
|
|
3505
|
+
throw error;
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
indexRelationshipChapter(workId, chapterId) {
|
|
3509
|
+
const chapter = this.store.db.get("SELECT id FROM chapters WHERE id = ? AND work_id = ?", chapterId, workId);
|
|
3510
|
+
if (!chapter)
|
|
3511
|
+
return;
|
|
3512
|
+
const paragraphs = this.store.db.all("SELECT id, search_content FROM chapter_paragraph_search WHERE chapter_id = ? ORDER BY paragraph_order", chapterId);
|
|
3513
|
+
for (const paragraph of paragraphs) {
|
|
3514
|
+
const rowId = Number(paragraph.id);
|
|
3515
|
+
this.store.db.run("DELETE FROM chapter_paragraph_pinyin_fts WHERE rowid = ?", rowId);
|
|
3516
|
+
this.store.db.run("INSERT INTO chapter_paragraph_pinyin_fts(rowid, pinyin_tokens) VALUES (?, ?)", rowId, relationshipPinyinTokenText(String(paragraph.search_content)));
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
indexRelationshipSettingSource(workId, sourceType, sourceId) {
|
|
3520
|
+
const materialized = this.relationshipSettingSource(workId, sourceType, sourceId);
|
|
3521
|
+
const existing = this.store.db.get("SELECT id FROM relationship_source_search WHERE work_id = ? AND source_type = ? AND source_id = ?", workId, sourceType, sourceId);
|
|
3522
|
+
if (!materialized) {
|
|
3523
|
+
if (existing)
|
|
3524
|
+
this.store.db.run("DELETE FROM relationship_source_search WHERE id = ?", Number(existing.id));
|
|
3525
|
+
return;
|
|
3526
|
+
}
|
|
3527
|
+
const searchable = `${materialized.title}\n${materialized.content}`;
|
|
3528
|
+
const contentHash = this.store.hashContent(searchable);
|
|
3529
|
+
let rowId = Number(existing?.id ?? 0);
|
|
3530
|
+
if (existing) {
|
|
3531
|
+
this.store.db.run(`UPDATE relationship_source_search SET source_version = ?, content_hash = ?, updated_at = ? WHERE id = ?`, materialized.version, contentHash, now(), rowId);
|
|
3532
|
+
this.store.db.run("DELETE FROM relationship_source_exact_fts WHERE rowid = ?", rowId);
|
|
3533
|
+
this.store.db.run("DELETE FROM relationship_source_pinyin_fts WHERE rowid = ?", rowId);
|
|
3534
|
+
}
|
|
3535
|
+
else {
|
|
3536
|
+
rowId = Number(this.store.db.run(`INSERT INTO relationship_source_search(work_id, source_type, source_id, source_version, content_hash, updated_at)
|
|
3537
|
+
VALUES (?, ?, ?, ?, ?, ?)`, workId, sourceType, sourceId, materialized.version, contentHash, now()).lastInsertRowid);
|
|
3538
|
+
}
|
|
3539
|
+
this.store.db.run("INSERT INTO relationship_source_exact_fts(rowid, character_tokens) VALUES (?, ?)", rowId, relationshipCharacterTokenText(searchable));
|
|
3540
|
+
this.store.db.run("INSERT INTO relationship_source_pinyin_fts(rowid, pinyin_tokens) VALUES (?, ?)", rowId, relationshipPinyinTokenText(searchable));
|
|
3541
|
+
}
|
|
3542
|
+
relationshipScopeChapterIds(workId, scope) {
|
|
3543
|
+
if (scope.type === "settings")
|
|
3544
|
+
return new Set();
|
|
3545
|
+
if (scope.type === "chapter") {
|
|
3546
|
+
if (!scope.chapterId)
|
|
3547
|
+
throw new AppError(400, "CHAPTER_REQUIRED", "分析范围缺少章节标识");
|
|
3548
|
+
const chapter = this.store.getChapter(scope.chapterId);
|
|
3549
|
+
if (String(chapter.workId) !== workId)
|
|
3550
|
+
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
3551
|
+
return new Set([scope.chapterId]);
|
|
3552
|
+
}
|
|
3553
|
+
if (scope.type === "volume") {
|
|
3554
|
+
if (!scope.volumeId)
|
|
3555
|
+
throw new AppError(400, "VOLUME_REQUIRED", "分析范围缺少分卷标识");
|
|
3556
|
+
const volume = this.store.getVolume(scope.volumeId);
|
|
3557
|
+
if (String(volume.workId) !== workId)
|
|
3558
|
+
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 = ?
|
|
3560
|
+
AND excluded_from_analysis = 0 AND chapter_type <> '作者的话'`, workId, scope.volumeId).map((row) => String(row.id)));
|
|
3561
|
+
}
|
|
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)));
|
|
3563
|
+
}
|
|
3564
|
+
relationshipIndexedSource(workId, sourceType, sourceId) {
|
|
3565
|
+
if (sourceType === "chapter") {
|
|
3566
|
+
try {
|
|
3567
|
+
const chapter = this.store.getChapter(sourceId);
|
|
3568
|
+
if (String(chapter.workId) !== workId)
|
|
3569
|
+
return null;
|
|
3570
|
+
return {
|
|
3571
|
+
sourceType,
|
|
3572
|
+
sourceId,
|
|
3573
|
+
title: String(chapter.title),
|
|
3574
|
+
content: String(chapter.content),
|
|
3575
|
+
version: String(chapter.versionNo)
|
|
3576
|
+
};
|
|
3577
|
+
}
|
|
3578
|
+
catch {
|
|
3579
|
+
return null;
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
const source = this.relationshipSettingSource(workId, sourceType, sourceId);
|
|
3583
|
+
return source ? {
|
|
3584
|
+
sourceType,
|
|
3585
|
+
sourceId,
|
|
3586
|
+
title: source.title,
|
|
3587
|
+
content: source.content,
|
|
3588
|
+
version: source.version
|
|
3589
|
+
} : null;
|
|
3590
|
+
}
|
|
3591
|
+
relationshipIndexedSourceKey(sourceType, sourceId) {
|
|
3592
|
+
return `${sourceType}:${sourceId}`;
|
|
3593
|
+
}
|
|
3594
|
+
relationshipIndexedSourceRef(key) {
|
|
3595
|
+
const separator = key.indexOf(":");
|
|
3596
|
+
return separator < 0
|
|
3597
|
+
? { sourceType: "setting", sourceId: key }
|
|
3598
|
+
: { sourceType: key.slice(0, separator), sourceId: key.slice(separator + 1) };
|
|
3599
|
+
}
|
|
3600
|
+
relationshipChapterExactMatches(workId, reference) {
|
|
3601
|
+
const normalized = normalizeRelationshipSearchText(reference).trim();
|
|
3602
|
+
if (!normalized)
|
|
3603
|
+
return [];
|
|
3604
|
+
const rows = [...normalized].length < 3
|
|
3605
|
+
? this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_short_terms term
|
|
3606
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = term.paragraph_id
|
|
3607
|
+
WHERE paragraph.work_id = ? AND term.term = ?`, workId, normalized)
|
|
3608
|
+
: this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_search_fts
|
|
3609
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_search_fts.rowid
|
|
3610
|
+
WHERE paragraph.work_id = ? AND chapter_paragraph_search_fts MATCH ?`, workId, `"${normalized.replaceAll('"', '""')}"`);
|
|
3611
|
+
return rows.map((row) => String(row.chapter_id));
|
|
3612
|
+
}
|
|
3613
|
+
relationshipSettingExactMatches(workId, reference) {
|
|
3614
|
+
const phrase = ftsPhrase(relationshipCharacterTokens(reference));
|
|
3615
|
+
return this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_exact_fts
|
|
3616
|
+
JOIN relationship_source_search source ON source.id = relationship_source_exact_fts.rowid
|
|
3617
|
+
WHERE source.work_id = ? AND relationship_source_exact_fts MATCH ?
|
|
3618
|
+
AND NOT (source.source_type = 'review' AND EXISTS (
|
|
3619
|
+
SELECT 1 FROM review_items review
|
|
3620
|
+
WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
|
|
3621
|
+
))`, workId, phrase).map((row) => this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
|
|
3622
|
+
}
|
|
3623
|
+
relationshipFuzzyIndexMatches(workId, reference, includeSettings, scope) {
|
|
3624
|
+
const result = new Set();
|
|
3625
|
+
const characterTokens = [...new Set(relationshipCharacterTokens(reference))];
|
|
3626
|
+
const pinyinTokens = [...new Set(relationshipPinyinTokens(reference))];
|
|
3627
|
+
const score = new Map();
|
|
3628
|
+
const add = (key) => {
|
|
3629
|
+
score.set(key, (score.get(key) ?? 0) + 1);
|
|
3630
|
+
};
|
|
3631
|
+
const chapterScope = scope.type === "chapter"
|
|
3632
|
+
? { sql: "AND paragraph.chapter_id = ?", params: [scope.chapterId ?? ""] }
|
|
3633
|
+
: scope.type === "volume"
|
|
3634
|
+
? {
|
|
3635
|
+
sql: `AND EXISTS (
|
|
3636
|
+
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 <> '作者的话'
|
|
3638
|
+
)`,
|
|
3639
|
+
params: [scope.volumeId ?? ""]
|
|
3640
|
+
}
|
|
3641
|
+
: {
|
|
3642
|
+
sql: `AND EXISTS (
|
|
3643
|
+
SELECT 1 FROM chapters chapter WHERE chapter.id = paragraph.chapter_id
|
|
3644
|
+
AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'
|
|
3645
|
+
)`,
|
|
3646
|
+
params: []
|
|
3647
|
+
};
|
|
3648
|
+
const includeChapters = scope.type !== "settings";
|
|
3649
|
+
const pinyinPhrase = ftsPhrase(relationshipPinyinTokens(reference));
|
|
3650
|
+
if (includeChapters) {
|
|
3651
|
+
for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_pinyin_fts
|
|
3652
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_pinyin_fts.rowid
|
|
3653
|
+
WHERE paragraph.work_id = ? AND chapter_paragraph_pinyin_fts MATCH ? ${chapterScope.sql}
|
|
3654
|
+
LIMIT 201`, workId, pinyinPhrase, ...chapterScope.params))
|
|
3655
|
+
result.add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
|
|
3656
|
+
}
|
|
3657
|
+
if (includeSettings) {
|
|
3658
|
+
for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_pinyin_fts
|
|
3659
|
+
JOIN relationship_source_search source ON source.id = relationship_source_pinyin_fts.rowid
|
|
3660
|
+
WHERE source.work_id = ? AND relationship_source_pinyin_fts MATCH ?
|
|
3661
|
+
AND NOT (source.source_type = 'review' AND EXISTS (
|
|
3662
|
+
SELECT 1 FROM review_items review
|
|
3663
|
+
WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
|
|
3664
|
+
))
|
|
3665
|
+
LIMIT 201`, workId, pinyinPhrase))
|
|
3666
|
+
result.add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
|
|
3667
|
+
}
|
|
3668
|
+
const normalizedCharacters = [...normalizeRelationshipSearchText(reference).trim()];
|
|
3669
|
+
if (includeChapters) {
|
|
3670
|
+
for (const character of [...new Set(normalizedCharacters)]) {
|
|
3671
|
+
for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_short_terms term
|
|
3672
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = term.paragraph_id
|
|
3673
|
+
WHERE paragraph.work_id = ? AND term.term = ? ${chapterScope.sql}
|
|
3674
|
+
LIMIT 201`, workId, character, ...chapterScope.params))
|
|
3675
|
+
add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
|
|
3676
|
+
}
|
|
3677
|
+
for (const token of pinyinTokens) {
|
|
3678
|
+
for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_pinyin_fts
|
|
3679
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_pinyin_fts.rowid
|
|
3680
|
+
WHERE paragraph.work_id = ? AND chapter_paragraph_pinyin_fts MATCH ? ${chapterScope.sql}
|
|
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
|
|
3688
|
+
JOIN relationship_source_search source ON source.id = relationship_source_exact_fts.rowid
|
|
3689
|
+
WHERE source.work_id = ? AND relationship_source_exact_fts MATCH ?
|
|
3690
|
+
AND NOT (source.source_type = 'review' AND EXISTS (
|
|
3691
|
+
SELECT 1 FROM review_items review
|
|
3692
|
+
WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
|
|
3693
|
+
))
|
|
3694
|
+
LIMIT 201`, workId, token))
|
|
3695
|
+
add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
|
|
3696
|
+
}
|
|
3697
|
+
for (const token of pinyinTokens) {
|
|
3698
|
+
for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_pinyin_fts
|
|
3699
|
+
JOIN relationship_source_search source ON source.id = relationship_source_pinyin_fts.rowid
|
|
3700
|
+
WHERE source.work_id = ? AND relationship_source_pinyin_fts MATCH ?
|
|
3701
|
+
AND NOT (source.source_type = 'review' AND EXISTS (
|
|
3702
|
+
SELECT 1 FROM review_items review
|
|
3703
|
+
WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
|
|
3704
|
+
))
|
|
3705
|
+
LIMIT 201`, workId, token))
|
|
3706
|
+
add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
const threshold = Math.max(1, [...normalizeRelationshipSearchText(reference).trim()].length - 1);
|
|
3710
|
+
for (const [key, count] of score)
|
|
3711
|
+
if (count >= threshold)
|
|
3712
|
+
result.add(key);
|
|
3713
|
+
return result;
|
|
3714
|
+
}
|
|
3715
|
+
relationshipIdentityAnchors(workId, character) {
|
|
3716
|
+
const characterId = String(character.id);
|
|
3717
|
+
const relatedIds = new Set();
|
|
3718
|
+
for (const relationship of this.store.listRelationships(workId)) {
|
|
3719
|
+
if (String(relationship.fromCharacterId) === characterId)
|
|
3720
|
+
relatedIds.add(String(relationship.toCharacterId));
|
|
3721
|
+
if (String(relationship.toCharacterId) === characterId)
|
|
3722
|
+
relatedIds.add(String(relationship.fromCharacterId));
|
|
3723
|
+
}
|
|
3724
|
+
const anchors = [
|
|
3725
|
+
String(character.code ?? ""),
|
|
3726
|
+
String(character.species ?? ""),
|
|
3727
|
+
String(character.race?.name ?? ""),
|
|
3728
|
+
...(Array.isArray(character.organizations) ? character.organizations.map((item) => String(item.name ?? "")) : []),
|
|
3729
|
+
...[...relatedIds].flatMap((relatedId) => {
|
|
3730
|
+
try {
|
|
3731
|
+
const related = this.store.getCharacter(relatedId);
|
|
3732
|
+
return [String(related.name), ...related.aliases];
|
|
3733
|
+
}
|
|
3734
|
+
catch {
|
|
3735
|
+
return [];
|
|
3736
|
+
}
|
|
3737
|
+
})
|
|
3738
|
+
].map((value) => normalizeRelationshipSearchText(value).trim())
|
|
3739
|
+
.filter((value) => [...value].length >= 2);
|
|
3740
|
+
return [...new Set(anchors)];
|
|
3741
|
+
}
|
|
3742
|
+
async localRelationshipSourceSelection(workId, scope, characters, selectedCharacterIds, generation) {
|
|
3743
|
+
const targetCharacters = characters.filter((character) => selectedCharacterIds.has(String(character.id)));
|
|
3744
|
+
const cacheKey = JSON.stringify({
|
|
3745
|
+
workId,
|
|
3746
|
+
scope: {
|
|
3747
|
+
type: scope.type,
|
|
3748
|
+
chapterId: scope.chapterId ?? null,
|
|
3749
|
+
volumeId: scope.volumeId ?? null,
|
|
3750
|
+
includeAllSettings: scope.includeAllSettings === true
|
|
3751
|
+
},
|
|
3752
|
+
targets: targetCharacters.map((character) => ({ id: character.id, versionNo: character.versionNo })),
|
|
3753
|
+
generation,
|
|
3754
|
+
policyVersion: RELATIONSHIP_SEARCH_POLICY_VERSION
|
|
3755
|
+
});
|
|
3756
|
+
const cached = this.relationshipSelectionCache.get(cacheKey);
|
|
3757
|
+
if (cached)
|
|
3758
|
+
return cached;
|
|
3759
|
+
const existingBuild = this.relationshipSelectionBuilds.get(cacheKey);
|
|
3760
|
+
if (existingBuild)
|
|
3761
|
+
return existingBuild;
|
|
3762
|
+
const build = (async () => {
|
|
3763
|
+
const allowedChapterIds = this.relationshipScopeChapterIds(workId, scope);
|
|
3764
|
+
const includeSettings = scope.type === "settings" || scope.includeAllSettings === true;
|
|
3765
|
+
const exactKeys = new Set();
|
|
3766
|
+
const candidates = [];
|
|
3767
|
+
const candidateKeys = new Set();
|
|
3768
|
+
const candidateOccurrences = new Map();
|
|
3769
|
+
const loadedSources = new Map();
|
|
3770
|
+
const knownCharacterReferences = new Set(characters.flatMap((character) => [
|
|
3771
|
+
String(character.name),
|
|
3772
|
+
...(Array.isArray(character.aliases) ? character.aliases.map(String) : [])
|
|
3773
|
+
]).map((value) => normalizeRelationshipSearchText(value).trim()).filter(Boolean));
|
|
3774
|
+
for (const character of targetCharacters) {
|
|
3775
|
+
const targetCharacterId = String(character.id);
|
|
3776
|
+
const exactReferences = [...new Set([String(character.name), ...character.aliases].map((item) => item.trim()).filter(Boolean))];
|
|
3777
|
+
const fuzzyReferenceCount = exactReferences.filter((reference) => [...normalizeRelationshipSearchText(reference).trim()].length >= 2).length;
|
|
3778
|
+
if (fuzzyReferenceCount > RELATIONSHIP_MAX_FUZZY_REFERENCES) {
|
|
3779
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "人物名称和别名过多,无法在安全预算内完成疑似写法匹配", {
|
|
3780
|
+
characterId: targetCharacterId,
|
|
3781
|
+
fuzzyReferenceCount,
|
|
3782
|
+
maximumFuzzyReferences: RELATIONSHIP_MAX_FUZZY_REFERENCES
|
|
3783
|
+
});
|
|
3784
|
+
}
|
|
3785
|
+
const normalizedExactReferences = new Set(exactReferences.map((item) => normalizeRelationshipSearchText(item).trim()));
|
|
3786
|
+
const anchors = this.relationshipIdentityAnchors(workId, character);
|
|
3787
|
+
const anchorKeys = new Set();
|
|
3788
|
+
for (const anchor of anchors) {
|
|
3789
|
+
for (const chapterId of this.relationshipChapterExactMatches(workId, anchor)) {
|
|
3790
|
+
if (allowedChapterIds.has(chapterId))
|
|
3791
|
+
anchorKeys.add(this.relationshipIndexedSourceKey("chapter", chapterId));
|
|
3792
|
+
}
|
|
3793
|
+
if (includeSettings)
|
|
3794
|
+
for (const key of this.relationshipSettingExactMatches(workId, anchor))
|
|
3795
|
+
anchorKeys.add(key);
|
|
3796
|
+
}
|
|
3797
|
+
const targetIndexCandidateKeys = new Set();
|
|
3798
|
+
const targetFuzzySourceKeys = new Set();
|
|
3799
|
+
let fuzzyScanCharacters = 0;
|
|
3800
|
+
let fuzzyMatchCount = 0;
|
|
3801
|
+
for (const reference of exactReferences) {
|
|
3802
|
+
for (const chapterId of this.relationshipChapterExactMatches(workId, reference)) {
|
|
3803
|
+
if (allowedChapterIds.has(chapterId))
|
|
3804
|
+
exactKeys.add(this.relationshipIndexedSourceKey("chapter", chapterId));
|
|
3805
|
+
}
|
|
3806
|
+
if (includeSettings)
|
|
3807
|
+
for (const key of this.relationshipSettingExactMatches(workId, reference))
|
|
3808
|
+
exactKeys.add(key);
|
|
3809
|
+
const referenceLength = [...normalizeRelationshipSearchText(reference).trim()].length;
|
|
3810
|
+
if (referenceLength < 2)
|
|
3811
|
+
continue;
|
|
3812
|
+
const fuzzyIndexKeys = referenceLength === 2
|
|
3813
|
+
? anchorKeys
|
|
3814
|
+
: this.relationshipFuzzyIndexMatches(workId, reference, includeSettings, scope);
|
|
3815
|
+
for (const key of fuzzyIndexKeys) {
|
|
3816
|
+
const ref = this.relationshipIndexedSourceRef(key);
|
|
3817
|
+
if (ref.sourceType === "chapter" && !allowedChapterIds.has(ref.sourceId))
|
|
3818
|
+
continue;
|
|
3819
|
+
if (ref.sourceType !== "chapter" && !includeSettings)
|
|
3820
|
+
continue;
|
|
3821
|
+
targetIndexCandidateKeys.add(key);
|
|
3822
|
+
if (targetIndexCandidateKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES) {
|
|
3823
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名来源过多,请补充人物别名或身份资料后重试", {
|
|
3824
|
+
characterId: targetCharacterId,
|
|
3825
|
+
candidateCount: targetIndexCandidateKeys.size,
|
|
3826
|
+
maximum: RELATIONSHIP_MAX_FUZZY_SOURCES
|
|
3827
|
+
});
|
|
3828
|
+
}
|
|
3829
|
+
let indexed = loadedSources.get(key);
|
|
3830
|
+
if (!indexed) {
|
|
3831
|
+
const loaded = this.relationshipIndexedSource(workId, ref.sourceType, ref.sourceId);
|
|
3832
|
+
if (!loaded)
|
|
3833
|
+
continue;
|
|
3834
|
+
indexed = loaded;
|
|
3835
|
+
loadedSources.set(key, indexed);
|
|
3836
|
+
}
|
|
3837
|
+
if (indexed.sourceType === "review" && indexed.content.includes('"itemType": "character-name-variant"'))
|
|
3838
|
+
continue;
|
|
3839
|
+
const searchable = `${indexed.title}\n${indexed.content}`;
|
|
3840
|
+
const normalizedSearchable = normalizeRelationshipSearchText(searchable);
|
|
3841
|
+
fuzzyScanCharacters += normalizedSearchable.length;
|
|
3842
|
+
if (fuzzyScanCharacters > RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS) {
|
|
3843
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名待核对文本过多,请缩小分析范围或补充人物别名", {
|
|
3844
|
+
characterId: targetCharacterId,
|
|
3845
|
+
scannedCharacters: fuzzyScanCharacters,
|
|
3846
|
+
maximumScannedCharacters: RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS
|
|
3847
|
+
});
|
|
3848
|
+
}
|
|
3849
|
+
const referenceCharacters = [...normalizeRelationshipSearchText(reference).trim()];
|
|
3850
|
+
let approximateMatches;
|
|
3851
|
+
try {
|
|
3852
|
+
approximateMatches = await findApproximateNameMatchesChunked(searchable, reference, 24, knownCharacterReferences, RELATIONSHIP_MAX_SOURCE_MATCHES);
|
|
3853
|
+
}
|
|
3854
|
+
catch (error) {
|
|
3855
|
+
if (!(error instanceof RelationshipApproximateMatchLimitError))
|
|
3856
|
+
throw error;
|
|
3857
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "单个来源中的疑似人物名写法过多,请缩小分析范围或补充人物别名", {
|
|
3858
|
+
characterId: targetCharacterId,
|
|
3859
|
+
sourceType: indexed.sourceType,
|
|
3860
|
+
sourceId: indexed.sourceId,
|
|
3861
|
+
maximumSourceMatches: error.maximumCandidates
|
|
3862
|
+
});
|
|
3863
|
+
}
|
|
3864
|
+
for (const match of approximateMatches) {
|
|
3865
|
+
if (normalizedExactReferences.has(normalizeRelationshipSearchText(match.observed).trim()))
|
|
3866
|
+
continue;
|
|
3867
|
+
if (referenceLength === 2
|
|
3868
|
+
&& !anchors.some((anchor) => normalizedSearchable.includes(anchor)))
|
|
3869
|
+
continue;
|
|
3870
|
+
const occurrenceKey = [targetCharacterId, indexed.sourceType, indexed.sourceId, match.observed].join("|");
|
|
3871
|
+
const occurrenceCount = candidateOccurrences.get(occurrenceKey) ?? 0;
|
|
3872
|
+
if (occurrenceCount >= 3)
|
|
3873
|
+
continue;
|
|
3874
|
+
const candidateKey = [targetCharacterId, indexed.sourceType, indexed.sourceId, match.observed, reference, match.start].join("|");
|
|
3875
|
+
if (candidateKeys.has(candidateKey))
|
|
3876
|
+
continue;
|
|
3877
|
+
candidateKeys.add(candidateKey);
|
|
3878
|
+
candidateOccurrences.set(occurrenceKey, occurrenceCount + 1);
|
|
3879
|
+
fuzzyMatchCount += 1;
|
|
3880
|
+
if (fuzzyMatchCount > RELATIONSHIP_MAX_FUZZY_MATCHES) {
|
|
3881
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名写法过多,请缩小分析范围或补充人物别名", {
|
|
3882
|
+
characterId: targetCharacterId,
|
|
3883
|
+
fuzzyMatchCount,
|
|
3884
|
+
maximumFuzzyMatches: RELATIONSHIP_MAX_FUZZY_MATCHES
|
|
3885
|
+
});
|
|
3886
|
+
}
|
|
3887
|
+
targetFuzzySourceKeys.add(key);
|
|
3888
|
+
const snippetStart = Math.max(0, match.utf16Start - 240);
|
|
3889
|
+
const snippetEnd = Math.min(normalizedSearchable.length, match.utf16End + 240);
|
|
3890
|
+
candidates.push({
|
|
3891
|
+
key: candidateKey,
|
|
3892
|
+
targetCharacterId,
|
|
3893
|
+
targetName: String(character.name),
|
|
3894
|
+
reference,
|
|
3895
|
+
sourceType: indexed.sourceType,
|
|
3896
|
+
sourceId: indexed.sourceId,
|
|
3897
|
+
sourceTitle: indexed.title,
|
|
3898
|
+
sourceVersion: indexed.version,
|
|
3899
|
+
observed: match.observed,
|
|
3900
|
+
snippet: normalizedSearchable.slice(snippetStart, snippetEnd),
|
|
3901
|
+
characterDistance: match.characterDistance,
|
|
3902
|
+
pinyinDistance: match.pinyinDistance
|
|
3903
|
+
});
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
if (targetFuzzySourceKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES) {
|
|
3908
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名来源过多,请补充人物别名或身份资料后重试", {
|
|
3909
|
+
characterId: targetCharacterId,
|
|
3910
|
+
candidateCount: targetFuzzySourceKeys.size,
|
|
3911
|
+
maximum: RELATIONSHIP_MAX_FUZZY_SOURCES
|
|
3912
|
+
});
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
const result = { generation, exactKeys: [...exactKeys], candidates };
|
|
3916
|
+
this.relationshipSelectionCache.set(cacheKey, result);
|
|
3917
|
+
if (this.relationshipSelectionCache.size > 128) {
|
|
3918
|
+
const oldest = this.relationshipSelectionCache.keys().next().value;
|
|
3919
|
+
if (typeof oldest === "string")
|
|
3920
|
+
this.relationshipSelectionCache.delete(oldest);
|
|
3921
|
+
}
|
|
3922
|
+
return result;
|
|
3923
|
+
})();
|
|
3924
|
+
this.relationshipSelectionBuilds.set(cacheKey, build);
|
|
3925
|
+
try {
|
|
3926
|
+
return await build;
|
|
3927
|
+
}
|
|
3928
|
+
finally {
|
|
3929
|
+
if (this.relationshipSelectionBuilds.get(cacheKey) === build)
|
|
3930
|
+
this.relationshipSelectionBuilds.delete(cacheKey);
|
|
3931
|
+
}
|
|
3932
|
+
}
|
|
3933
|
+
async verifyRelationshipVariantCandidates(workId, candidates, modelId, taskId) {
|
|
3934
|
+
if (candidates.length === 0)
|
|
3935
|
+
return { decisions: [], callIds: [] };
|
|
3936
|
+
const batches = [];
|
|
3937
|
+
let batch = [];
|
|
3938
|
+
let batchLength = 0;
|
|
3939
|
+
for (const candidate of candidates) {
|
|
3940
|
+
const length = JSON.stringify(candidate).length;
|
|
3941
|
+
if (batch.length > 0 && batchLength + length > 12_000) {
|
|
3942
|
+
batches.push(batch);
|
|
3943
|
+
batch = [];
|
|
3944
|
+
batchLength = 0;
|
|
3945
|
+
}
|
|
3946
|
+
batch.push(candidate);
|
|
3947
|
+
batchLength += length;
|
|
3948
|
+
}
|
|
3949
|
+
if (batch.length > 0)
|
|
3950
|
+
batches.push(batch);
|
|
3951
|
+
const decisions = [];
|
|
3952
|
+
const callIds = [];
|
|
3953
|
+
try {
|
|
3954
|
+
for (const candidateBatch of batches) {
|
|
3955
|
+
const snippets = candidateBatch.map((candidate) => {
|
|
3956
|
+
const tag = candidate.sourceType === "chapter" ? "CHAPTER" : "SETTING";
|
|
3957
|
+
return [
|
|
3958
|
+
`<${tag} id="${candidate.sourceId.replaceAll('"', "'")}" title="${candidate.sourceTitle.replaceAll('"', "'")}">`,
|
|
3959
|
+
JSON.stringify({
|
|
3960
|
+
key: candidate.key,
|
|
3961
|
+
targetCharacterId: candidate.targetCharacterId,
|
|
3962
|
+
targetName: candidate.targetName,
|
|
3963
|
+
registeredReference: candidate.reference,
|
|
3964
|
+
observed: candidate.observed,
|
|
3965
|
+
characterDistance: candidate.characterDistance,
|
|
3966
|
+
pinyinDistance: candidate.pinyinDistance,
|
|
3967
|
+
snippet: candidate.snippet
|
|
3968
|
+
}),
|
|
3969
|
+
`</${tag}>`
|
|
3970
|
+
].join("\n");
|
|
3971
|
+
}).join("\n");
|
|
3972
|
+
const generated = await this.generateTaggedJson({
|
|
3973
|
+
workId,
|
|
3974
|
+
taskId,
|
|
3975
|
+
taskType: "relationship-analysis",
|
|
3976
|
+
signal: this.taskSignal(taskId),
|
|
3977
|
+
maxAttempts: 2,
|
|
3978
|
+
scope: { type: "selection", selection: snippets, suppressAutomaticContext: true },
|
|
3979
|
+
...(modelId ? { modelId } : {}),
|
|
3980
|
+
parameters: { temperature: 0.1 },
|
|
3981
|
+
instruction: [
|
|
3982
|
+
"你是人物名称变体确认器。判断每个片段中的 observed 是否指向对应 targetName,而不是另一个人物、普通词语或无法判断的对象。",
|
|
3983
|
+
"只依据每个候选附带的局部片段判断,禁止使用未提供的正文或设定。",
|
|
3984
|
+
"必须为每个 key 恰好输出一次结果,不得遗漏、重复或新增 key。",
|
|
3985
|
+
"verdict 只能是 same、separate、uncertain;confidence 是 0 到 1;reason 使用简短中文说明片段内依据。",
|
|
3986
|
+
"拼音相同或字形相近只能说明疑似,不能单独作为 same 的依据。上下文不能可靠确认时必须输出 uncertain。",
|
|
3987
|
+
"输出 JSON 数组,字段为 key、verdict、confidence、reason。"
|
|
3988
|
+
].join("\n")
|
|
3989
|
+
});
|
|
3990
|
+
callIds.push(generated.callId);
|
|
3991
|
+
const extracted = extractJson(generated.content);
|
|
3992
|
+
if (!Array.isArray(extracted))
|
|
3993
|
+
throw new Error("variant verification result is not an array");
|
|
3994
|
+
const byKey = new Map(candidateBatch.map((candidate) => [candidate.key, candidate]));
|
|
3995
|
+
const seen = new Set();
|
|
3996
|
+
for (const item of extracted) {
|
|
3997
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
3998
|
+
throw new Error("variant verification item is invalid");
|
|
3999
|
+
const value = item;
|
|
4000
|
+
const key = typeof value.key === "string" ? value.key : "";
|
|
4001
|
+
const verdict = value.verdict;
|
|
4002
|
+
const confidence = Number(value.confidence);
|
|
4003
|
+
const reason = typeof value.reason === "string" ? value.reason.trim() : "";
|
|
4004
|
+
const candidate = byKey.get(key);
|
|
4005
|
+
if (!candidate || seen.has(key) || !["same", "separate", "uncertain"].includes(String(verdict))
|
|
4006
|
+
|| !Number.isFinite(confidence) || confidence < 0 || confidence > 1 || !reason) {
|
|
4007
|
+
throw new Error("variant verification item is incomplete");
|
|
4008
|
+
}
|
|
4009
|
+
seen.add(key);
|
|
4010
|
+
decisions.push({
|
|
4011
|
+
...candidate,
|
|
4012
|
+
verdict: verdict,
|
|
4013
|
+
confidence,
|
|
4014
|
+
reason
|
|
4015
|
+
});
|
|
4016
|
+
}
|
|
4017
|
+
if (seen.size !== candidateBatch.length)
|
|
4018
|
+
throw new Error("variant verification result omitted candidates");
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
catch (error) {
|
|
4022
|
+
if (error instanceof AppError && error.code === "TASK_CANCELLED")
|
|
4023
|
+
throw error;
|
|
4024
|
+
throw new AppError(502, "RELATIONSHIP_VARIANT_VERIFICATION_FAILED", "疑似人物名身份确认失败,未写入人物关系", {
|
|
4025
|
+
candidateCount: candidates.length,
|
|
4026
|
+
completedCallCount: callIds.length
|
|
4027
|
+
});
|
|
4028
|
+
}
|
|
4029
|
+
return { decisions, callIds };
|
|
4030
|
+
}
|
|
4031
|
+
async selectRelationshipSources(workId, scope, characters, selectedCharacterIds, modelId, taskId) {
|
|
4032
|
+
let generation;
|
|
4033
|
+
try {
|
|
4034
|
+
generation = await this.ensureRelationshipSearchIndex(workId);
|
|
4035
|
+
}
|
|
4036
|
+
catch {
|
|
4037
|
+
throw new AppError(503, "RELATIONSHIP_INDEX_BUILD_FAILED", "人物关系来源索引构建失败,请稍后重试");
|
|
4038
|
+
}
|
|
4039
|
+
const local = await this.localRelationshipSourceSelection(workId, scope, characters, selectedCharacterIds, generation);
|
|
4040
|
+
const verified = await this.verifyRelationshipVariantCandidates(workId, local.candidates, modelId, taskId);
|
|
4041
|
+
const accepted = verified.decisions.filter((decision) => decision.verdict === "same" && decision.confidence >= 0.8);
|
|
4042
|
+
const selectedKeys = new Set([...local.exactKeys, ...accepted.map((decision) => this.relationshipIndexedSourceKey(decision.sourceType, decision.sourceId))]);
|
|
4043
|
+
const chapters = [];
|
|
4044
|
+
const settings = [];
|
|
4045
|
+
for (const key of selectedKeys) {
|
|
4046
|
+
const ref = this.relationshipIndexedSourceRef(key);
|
|
4047
|
+
if (ref.sourceType === "chapter") {
|
|
4048
|
+
const source = this.relationshipIndexedSource(workId, ref.sourceType, ref.sourceId);
|
|
4049
|
+
if (source)
|
|
4050
|
+
chapters.push({
|
|
4051
|
+
id: source.sourceId,
|
|
4052
|
+
workId,
|
|
4053
|
+
title: source.title,
|
|
4054
|
+
content: collapseAiBlankLines(source.content),
|
|
4055
|
+
versionNo: Number(source.version)
|
|
4056
|
+
});
|
|
4057
|
+
}
|
|
4058
|
+
else {
|
|
4059
|
+
const source = this.relationshipSettingSource(workId, ref.sourceType, ref.sourceId);
|
|
4060
|
+
if (source)
|
|
4061
|
+
settings.push(source);
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
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]));
|
|
4066
|
+
chapters.sort((left, right) => (chapterOrder.get(String(left.id)) ?? Number.MAX_SAFE_INTEGER) - (chapterOrder.get(String(right.id)) ?? Number.MAX_SAFE_INTEGER));
|
|
4067
|
+
settings.sort((left, right) => `${left.sourceType}:${left.id}`.localeCompare(`${right.sourceType}:${right.id}`, "zh-CN"));
|
|
4068
|
+
return {
|
|
4069
|
+
generation,
|
|
4070
|
+
chapters,
|
|
4071
|
+
settings,
|
|
4072
|
+
variantDecisions: verified.decisions,
|
|
4073
|
+
verificationCallIds: verified.callIds,
|
|
4074
|
+
summary: {
|
|
4075
|
+
policyVersion: RELATIONSHIP_SEARCH_POLICY_VERSION,
|
|
4076
|
+
indexGeneration: generation,
|
|
4077
|
+
exactSourceCount: new Set(local.exactKeys).size,
|
|
4078
|
+
fuzzyCandidateCount: local.candidates.length,
|
|
4079
|
+
confirmedSourceCount: new Set(accepted.map((decision) => this.relationshipIndexedSourceKey(decision.sourceType, decision.sourceId))).size,
|
|
4080
|
+
rejectedSourceCount: verified.decisions.filter((decision) => decision.verdict === "separate").length,
|
|
4081
|
+
uncertainSourceCount: verified.decisions.filter((decision) => decision.verdict === "uncertain" || (decision.verdict === "same" && decision.confidence < 0.8)).length,
|
|
4082
|
+
reviewIds: []
|
|
4083
|
+
}
|
|
4084
|
+
};
|
|
4085
|
+
}
|
|
4086
|
+
relationshipSettingSource(workId, sourceType, sourceId) {
|
|
3302
4087
|
const cleanStrings = (value) => {
|
|
3303
4088
|
if (typeof value === "string")
|
|
3304
4089
|
return collapseAiBlankLines(value);
|
|
@@ -3309,123 +4094,175 @@ export class AiManager {
|
|
|
3309
4094
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cleanStrings(item)]));
|
|
3310
4095
|
};
|
|
3311
4096
|
const serialize = (value) => JSON.stringify(cleanStrings(value), null, 2);
|
|
3312
|
-
const source = (
|
|
3313
|
-
id: sourceType === "setting" ?
|
|
4097
|
+
const source = (title, value, version) => ({
|
|
4098
|
+
id: sourceType === "setting" ? sourceId : `${sourceType}:${sourceId}`,
|
|
3314
4099
|
title,
|
|
3315
4100
|
sourceType,
|
|
3316
|
-
content: serialize(value)
|
|
4101
|
+
content: serialize(value),
|
|
4102
|
+
version: String(version ?? "")
|
|
4103
|
+
});
|
|
4104
|
+
try {
|
|
4105
|
+
if (sourceType === "work") {
|
|
4106
|
+
const item = this.store.getWork(sourceId);
|
|
4107
|
+
if (String(item.id) !== workId)
|
|
4108
|
+
return null;
|
|
4109
|
+
return source(`作品资料:${String(item.title)}`, {
|
|
4110
|
+
title: item.title, author: item.author, description: item.description, language: item.language
|
|
4111
|
+
}, item.versionNo);
|
|
4112
|
+
}
|
|
4113
|
+
if (sourceType === "setting") {
|
|
4114
|
+
const item = this.store.getSetting(sourceId);
|
|
4115
|
+
if (String(item.workId) !== workId)
|
|
4116
|
+
return null;
|
|
4117
|
+
return source(String(item.title), {
|
|
4118
|
+
category: item.category, content: item.content, tags: item.tags, status: item.status, authorNote: item.authorNote
|
|
4119
|
+
}, item.versionNo ?? item.updatedAt);
|
|
4120
|
+
}
|
|
4121
|
+
if (sourceType === "character") {
|
|
4122
|
+
const item = this.store.getCharacter(sourceId);
|
|
4123
|
+
if (String(item.workId) !== workId)
|
|
4124
|
+
return null;
|
|
4125
|
+
return source(`人物档案:${String(item.name)}`, {
|
|
4126
|
+
name: item.name, aliases: item.aliases, code: item.code, species: item.species, race: item.race,
|
|
4127
|
+
organizations: item.organizations, attributes: item.attributes, profile: item.profile,
|
|
4128
|
+
currentState: item.currentState, lockedFields: item.lockedFields
|
|
4129
|
+
}, item.versionNo);
|
|
4130
|
+
}
|
|
4131
|
+
if (sourceType === "race") {
|
|
4132
|
+
const item = this.store.getRace(sourceId);
|
|
4133
|
+
if (String(item.workId) !== workId)
|
|
4134
|
+
return null;
|
|
4135
|
+
return source(`种族设定:${String(item.name)}`, {
|
|
4136
|
+
name: item.name, description: item.description, lineage: item.lineage, settings: item.settings,
|
|
4137
|
+
effectiveSettings: item.effectiveSettings, members: item.members
|
|
4138
|
+
}, item.versionNo);
|
|
4139
|
+
}
|
|
4140
|
+
if (sourceType === "organization") {
|
|
4141
|
+
const item = this.store.getOrganization(sourceId);
|
|
4142
|
+
if (String(item.workId) !== workId)
|
|
4143
|
+
return null;
|
|
4144
|
+
return source(`组织设定:${String(item.name)}`, {
|
|
4145
|
+
name: item.name, description: item.description, settings: item.settings, members: item.members
|
|
4146
|
+
}, item.versionNo);
|
|
4147
|
+
}
|
|
4148
|
+
if (sourceType === "timeline-track") {
|
|
4149
|
+
const item = this.store.getTimelineTrack(sourceId);
|
|
4150
|
+
if (String(item.workId) !== workId)
|
|
4151
|
+
return null;
|
|
4152
|
+
return source(`时间轴:${String(item.name)}`, { name: item.name, description: item.description }, item.versionNo);
|
|
4153
|
+
}
|
|
4154
|
+
if (sourceType === "timeline-event") {
|
|
4155
|
+
const item = this.store.getTimelineEvent(sourceId);
|
|
4156
|
+
if (String(item.workId) !== workId)
|
|
4157
|
+
return null;
|
|
4158
|
+
return source(`时间线事件:${String(item.name)}`, {
|
|
4159
|
+
name: item.name,
|
|
4160
|
+
description: item.description,
|
|
4161
|
+
eventType: item.eventType,
|
|
4162
|
+
timeLabel: item.timeLabel,
|
|
4163
|
+
participants: (Array.isArray(item.participantIds) ? item.participantIds : []).map((characterId) => {
|
|
4164
|
+
try {
|
|
4165
|
+
return { characterId, name: this.store.getCharacter(String(characterId)).name };
|
|
4166
|
+
}
|
|
4167
|
+
catch {
|
|
4168
|
+
return { characterId, name: "已删除角色" };
|
|
4169
|
+
}
|
|
4170
|
+
}),
|
|
4171
|
+
location: item.location,
|
|
4172
|
+
causes: item.causes,
|
|
4173
|
+
impactScope: item.impactScope,
|
|
4174
|
+
evidence: item.evidence,
|
|
4175
|
+
status: item.status
|
|
4176
|
+
}, item.versionNo);
|
|
4177
|
+
}
|
|
4178
|
+
if (sourceType === "relationship") {
|
|
4179
|
+
const item = this.store.getRelationship(sourceId);
|
|
4180
|
+
if (String(item.workId) !== workId)
|
|
4181
|
+
return null;
|
|
4182
|
+
const fromName = String(this.store.getCharacter(String(item.fromCharacterId)).name);
|
|
4183
|
+
const toName = String(this.store.getCharacter(String(item.toCharacterId)).name);
|
|
4184
|
+
return source(`人物关系:${fromName} / ${toName}`, {
|
|
4185
|
+
fromCharacter: { id: item.fromCharacterId, name: fromName },
|
|
4186
|
+
toCharacter: { id: item.toCharacterId, name: toName },
|
|
4187
|
+
category: item.category,
|
|
4188
|
+
subtype: item.subtype,
|
|
4189
|
+
keywords: item.keywords,
|
|
4190
|
+
directed: item.directed,
|
|
4191
|
+
currentStatus: item.currentStatus,
|
|
4192
|
+
timeRange: item.timeRange,
|
|
4193
|
+
confidence: item.confidence,
|
|
4194
|
+
evidence: item.evidence,
|
|
4195
|
+
confirmationStatus: item.confirmationStatus,
|
|
4196
|
+
locked: item.locked
|
|
4197
|
+
}, item.versionNo);
|
|
4198
|
+
}
|
|
4199
|
+
if (sourceType === "chapter-outline") {
|
|
4200
|
+
const item = this.store.getChapterOutline(sourceId);
|
|
4201
|
+
if (!item || String(item.workId) !== workId)
|
|
4202
|
+
return null;
|
|
4203
|
+
const volumeTitle = String(this.store.getVolume(String(item.volumeId)).title);
|
|
4204
|
+
return source(`章节大纲:${volumeTitle} / ${String(item.chapterTitle)}`, {
|
|
4205
|
+
chapterTitle: item.chapterTitle,
|
|
4206
|
+
volumeTitle,
|
|
4207
|
+
goal: item.goal,
|
|
4208
|
+
conflict: item.conflict,
|
|
4209
|
+
turningPoint: item.turningPoint,
|
|
4210
|
+
notes: item.notes,
|
|
4211
|
+
status: item.status
|
|
4212
|
+
}, item.versionNo ?? item.updatedAt);
|
|
4213
|
+
}
|
|
4214
|
+
if (sourceType === "foreshadow") {
|
|
4215
|
+
const item = this.store.getForeshadow(sourceId);
|
|
4216
|
+
if (String(item.workId) !== workId)
|
|
4217
|
+
return null;
|
|
4218
|
+
return source(`伏笔:${String(item.title)}`, {
|
|
4219
|
+
title: item.title,
|
|
4220
|
+
description: item.description,
|
|
4221
|
+
status: item.status,
|
|
4222
|
+
importance: item.importance,
|
|
4223
|
+
resolutionNote: item.resolutionNote,
|
|
4224
|
+
occurrences: item.occurrences
|
|
4225
|
+
}, item.versionNo);
|
|
4226
|
+
}
|
|
4227
|
+
if (sourceType === "review") {
|
|
4228
|
+
const item = this.store.getReviewItem(sourceId);
|
|
4229
|
+
if (String(item.workId) !== workId)
|
|
4230
|
+
return null;
|
|
4231
|
+
return source(`审核项:${String(item.title)}`, {
|
|
4232
|
+
itemType: item.itemType,
|
|
4233
|
+
severity: item.severity,
|
|
4234
|
+
title: item.title,
|
|
4235
|
+
description: item.description,
|
|
4236
|
+
evidence: item.evidence,
|
|
4237
|
+
suggestion: item.suggestion,
|
|
4238
|
+
status: item.status,
|
|
4239
|
+
resolutionNote: item.resolutionNote
|
|
4240
|
+
}, item.updatedAt);
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
catch {
|
|
4244
|
+
return null;
|
|
4245
|
+
}
|
|
4246
|
+
return null;
|
|
4247
|
+
}
|
|
4248
|
+
relationshipSettingSources(workId, characters) {
|
|
4249
|
+
const refs = [
|
|
4250
|
+
["work", workId],
|
|
4251
|
+
...this.store.listSettings(workId).map((item) => ["setting", String(item.id)]),
|
|
4252
|
+
...characters.map((item) => ["character", String(item.id)]),
|
|
4253
|
+
...this.store.listRaces(workId).map((item) => ["race", String(item.id)]),
|
|
4254
|
+
...this.store.listOrganizations(workId).map((item) => ["organization", String(item.id)]),
|
|
4255
|
+
...this.store.listTimelineTracks(workId).map((item) => ["timeline-track", String(item.id)]),
|
|
4256
|
+
...this.store.listTimelineEvents(workId).map((item) => ["timeline-event", String(item.id)]),
|
|
4257
|
+
...this.store.listRelationships(workId).map((item) => ["relationship", String(item.id)]),
|
|
4258
|
+
...this.store.listChapterOutlines(workId).map((item) => ["chapter-outline", String(item.chapterId)]),
|
|
4259
|
+
...this.store.listForeshadows(workId).map((item) => ["foreshadow", String(item.id)]),
|
|
4260
|
+
...this.store.listReviewItems(workId).map((item) => ["review", String(item.id)])
|
|
4261
|
+
];
|
|
4262
|
+
return refs.flatMap(([sourceType, sourceId]) => {
|
|
4263
|
+
const materialized = this.relationshipSettingSource(workId, sourceType, sourceId);
|
|
4264
|
+
return materialized ? [materialized] : [];
|
|
3317
4265
|
});
|
|
3318
|
-
const work = this.store.getWork(workId);
|
|
3319
|
-
const settings = this.store.listSettings(workId).map((item) => source("setting", item.id, String(item.title), {
|
|
3320
|
-
category: item.category,
|
|
3321
|
-
content: item.content,
|
|
3322
|
-
tags: item.tags,
|
|
3323
|
-
status: item.status,
|
|
3324
|
-
authorNote: item.authorNote
|
|
3325
|
-
}));
|
|
3326
|
-
const characterSources = this.store.listCharacters(workId, true).map((item) => source("character", item.id, `人物档案:${String(item.name)}`, {
|
|
3327
|
-
name: item.name,
|
|
3328
|
-
aliases: item.aliases,
|
|
3329
|
-
code: item.code,
|
|
3330
|
-
species: item.species,
|
|
3331
|
-
race: item.race,
|
|
3332
|
-
organizations: item.organizations,
|
|
3333
|
-
attributes: item.attributes,
|
|
3334
|
-
profile: item.profile,
|
|
3335
|
-
currentState: item.currentState,
|
|
3336
|
-
lockedFields: item.lockedFields
|
|
3337
|
-
}));
|
|
3338
|
-
const races = this.store.listRaces(workId).map((item) => source("race", item.id, `种族设定:${String(item.name)}`, {
|
|
3339
|
-
name: item.name,
|
|
3340
|
-
description: item.description,
|
|
3341
|
-
lineage: item.lineage,
|
|
3342
|
-
settings: item.settings,
|
|
3343
|
-
effectiveSettings: item.effectiveSettings,
|
|
3344
|
-
members: item.members
|
|
3345
|
-
}));
|
|
3346
|
-
const organizations = this.store.listOrganizations(workId).map((item) => source("organization", item.id, `组织设定:${String(item.name)}`, {
|
|
3347
|
-
name: item.name,
|
|
3348
|
-
description: item.description,
|
|
3349
|
-
settings: item.settings,
|
|
3350
|
-
members: item.members
|
|
3351
|
-
}));
|
|
3352
|
-
const tracks = this.store.listTimelineTracks(workId).map((item) => source("timeline-track", item.id, `时间轴:${String(item.name)}`, {
|
|
3353
|
-
name: item.name,
|
|
3354
|
-
description: item.description
|
|
3355
|
-
}));
|
|
3356
|
-
const timeline = this.store.listTimelineEvents(workId).map((item) => source("timeline-event", item.id, `时间线事件:${String(item.name)}`, {
|
|
3357
|
-
name: item.name,
|
|
3358
|
-
description: item.description,
|
|
3359
|
-
eventType: item.eventType,
|
|
3360
|
-
timeLabel: item.timeLabel,
|
|
3361
|
-
participants: (Array.isArray(item.participantIds) ? item.participantIds : []).map((characterId) => ({
|
|
3362
|
-
characterId,
|
|
3363
|
-
name: characterNameById.get(String(characterId)) ?? "已删除角色"
|
|
3364
|
-
})),
|
|
3365
|
-
location: item.location,
|
|
3366
|
-
causes: item.causes,
|
|
3367
|
-
impactScope: item.impactScope,
|
|
3368
|
-
evidence: item.evidence,
|
|
3369
|
-
status: item.status
|
|
3370
|
-
}));
|
|
3371
|
-
const relationships = this.store.listRelationships(workId).map((item) => source("relationship", item.id, `人物关系:${characterNameById.get(String(item.fromCharacterId)) ?? "已删除角色"} / ${characterNameById.get(String(item.toCharacterId)) ?? "已删除角色"}`, {
|
|
3372
|
-
fromCharacter: { id: item.fromCharacterId, name: characterNameById.get(String(item.fromCharacterId)) ?? "已删除角色" },
|
|
3373
|
-
toCharacter: { id: item.toCharacterId, name: characterNameById.get(String(item.toCharacterId)) ?? "已删除角色" },
|
|
3374
|
-
category: item.category,
|
|
3375
|
-
subtype: item.subtype,
|
|
3376
|
-
keywords: item.keywords,
|
|
3377
|
-
directed: item.directed,
|
|
3378
|
-
currentStatus: item.currentStatus,
|
|
3379
|
-
timeRange: item.timeRange,
|
|
3380
|
-
confidence: item.confidence,
|
|
3381
|
-
evidence: item.evidence,
|
|
3382
|
-
confirmationStatus: item.confirmationStatus,
|
|
3383
|
-
locked: item.locked
|
|
3384
|
-
}));
|
|
3385
|
-
const outlines = this.store.listChapterOutlines(workId).map((item) => source("chapter-outline", item.chapterId, `章节大纲:${String(item.volumeTitle)} / ${String(item.chapterTitle)}`, {
|
|
3386
|
-
chapterTitle: item.chapterTitle,
|
|
3387
|
-
volumeTitle: item.volumeTitle,
|
|
3388
|
-
goal: item.goal,
|
|
3389
|
-
conflict: item.conflict,
|
|
3390
|
-
turningPoint: item.turningPoint,
|
|
3391
|
-
notes: item.notes,
|
|
3392
|
-
status: item.status
|
|
3393
|
-
}));
|
|
3394
|
-
const foreshadows = this.store.listForeshadows(workId).map((item) => source("foreshadow", item.id, `伏笔:${String(item.title)}`, {
|
|
3395
|
-
title: item.title,
|
|
3396
|
-
description: item.description,
|
|
3397
|
-
status: item.status,
|
|
3398
|
-
importance: item.importance,
|
|
3399
|
-
resolutionNote: item.resolutionNote,
|
|
3400
|
-
occurrences: item.occurrences
|
|
3401
|
-
}));
|
|
3402
|
-
const reviews = this.store.listReviewItems(workId).map((item) => source("review", item.id, `审核项:${String(item.title)}`, {
|
|
3403
|
-
itemType: item.itemType,
|
|
3404
|
-
severity: item.severity,
|
|
3405
|
-
title: item.title,
|
|
3406
|
-
description: item.description,
|
|
3407
|
-
evidence: item.evidence,
|
|
3408
|
-
suggestion: item.suggestion,
|
|
3409
|
-
status: item.status,
|
|
3410
|
-
resolutionNote: item.resolutionNote
|
|
3411
|
-
}));
|
|
3412
|
-
return [source("work", work.id, `作品资料:${String(work.title)}`, {
|
|
3413
|
-
title: work.title,
|
|
3414
|
-
author: work.author,
|
|
3415
|
-
description: work.description,
|
|
3416
|
-
language: work.language
|
|
3417
|
-
}), ...settings, ...characterSources, ...races, ...organizations, ...tracks, ...timeline, ...relationships, ...outlines, ...foreshadows, ...reviews];
|
|
3418
|
-
}
|
|
3419
|
-
relationshipSearchKeywords(characters, selectedCharacterIds) {
|
|
3420
|
-
return [...new Set(characters
|
|
3421
|
-
.filter((character) => selectedCharacterIds.has(String(character.id)))
|
|
3422
|
-
.flatMap((character) => [String(character.name), ...(character.aliases ?? []).map(String)])
|
|
3423
|
-
.map((keyword) => keyword.normalize("NFKC").trim().toLocaleLowerCase("zh-CN"))
|
|
3424
|
-
.filter(Boolean))];
|
|
3425
|
-
}
|
|
3426
|
-
relationshipSourceContainsKeyword(source, keywords) {
|
|
3427
|
-
const searchable = `${String(source.title ?? "")}\n${String(source.content ?? "")}`.normalize("NFKC").toLocaleLowerCase("zh-CN");
|
|
3428
|
-
return keywords.some((keyword) => searchable.includes(keyword));
|
|
3429
4266
|
}
|
|
3430
4267
|
async runRelationshipAnalysis(workId, scope, modelId, taskId) {
|
|
3431
4268
|
const characters = this.store.listCharacters(workId);
|
|
@@ -3443,20 +4280,18 @@ export class AiManager {
|
|
|
3443
4280
|
.filter((character) => selectedCharacterIds.has(String(character.id)))
|
|
3444
4281
|
.map((character) => `${String(character.id)} | ${String(character.name)}`)
|
|
3445
4282
|
.join("\n");
|
|
3446
|
-
const
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
const availableSettings = settingsOnly || scope.includeAllSettings === true
|
|
4283
|
+
const sourceSelection = targeted
|
|
4284
|
+
? await this.selectRelationshipSources(workId, scope, characters, selectedCharacterIds, modelId, taskId)
|
|
4285
|
+
: null;
|
|
4286
|
+
const scopedChapters = targeted ? [] : settingsOnly ? [] : this.getScopeChapters(workId, scope);
|
|
4287
|
+
const chapters = sourceSelection?.chapters ?? scopedChapters;
|
|
4288
|
+
const availableSettings = targeted ? [] : settingsOnly || scope.includeAllSettings === true
|
|
3452
4289
|
? this.relationshipSettingSources(workId, characters)
|
|
3453
4290
|
: [];
|
|
3454
|
-
const settings =
|
|
3455
|
-
|
|
3456
|
-
: availableSettings;
|
|
3457
|
-
if (settingsOnly && availableSettings.length === 0)
|
|
4291
|
+
const settings = sourceSelection?.settings ?? availableSettings;
|
|
4292
|
+
if (!targeted && settingsOnly && availableSettings.length === 0)
|
|
3458
4293
|
throw new AppError(409, "SETTINGS_REQUIRED", "人物关系分析范围内没有设定数据");
|
|
3459
|
-
if (!settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
|
|
4294
|
+
if (!targeted && !settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
|
|
3460
4295
|
throw new AppError(409, "RELATIONSHIP_SOURCES_REQUIRED", "人物关系分析范围内没有章节或设定数据");
|
|
3461
4296
|
}
|
|
3462
4297
|
const chunks = [
|
|
@@ -3478,7 +4313,8 @@ export class AiManager {
|
|
|
3478
4313
|
targetedEvidenceCount: 0,
|
|
3479
4314
|
aggregationBatchCount: 0,
|
|
3480
4315
|
replacedRelationshipCount: 0,
|
|
3481
|
-
|
|
4316
|
+
sourceSelection: sourceSelection?.summary,
|
|
4317
|
+
callIds: sourceSelection?.verificationCallIds ?? []
|
|
3482
4318
|
};
|
|
3483
4319
|
}
|
|
3484
4320
|
const concurrency = this.configuredConcurrency(workId, "relationship-analysis", modelId);
|
|
@@ -3489,7 +4325,7 @@ export class AiManager {
|
|
|
3489
4325
|
const rawCandidates = [];
|
|
3490
4326
|
const chapterEvidenceCandidates = [];
|
|
3491
4327
|
const settingCandidates = [];
|
|
3492
|
-
const callIds = [];
|
|
4328
|
+
const callIds = [...(sourceSelection?.verificationCallIds ?? [])];
|
|
3493
4329
|
const settingsInstruction = [
|
|
3494
4330
|
"你是小说人物关系设定抽取器,不是续写者。只根据本批系统设定数据抽取角色规范表中人物之间被明确写出的长期关系。",
|
|
3495
4331
|
...(targeted ? ["被分析角色:", targetedRoster, "只输出至少一端属于被分析角色的关系。"] : []),
|
|
@@ -4079,6 +4915,52 @@ export class AiManager {
|
|
|
4079
4915
|
if (taskId && settingsOnly)
|
|
4080
4916
|
this.store.refreshTaskSourceVersions(taskId);
|
|
4081
4917
|
});
|
|
4918
|
+
if (sourceSelection) {
|
|
4919
|
+
const acceptedVariants = sourceSelection.variantDecisions.filter((decision) => decision.verdict === "same" && decision.confidence >= 0.8);
|
|
4920
|
+
const reviewIds = new Set();
|
|
4921
|
+
this.store.db.transaction(() => {
|
|
4922
|
+
for (const decision of acceptedVariants) {
|
|
4923
|
+
const observedIndex = decision.snippet.indexOf(decision.observed);
|
|
4924
|
+
const quote = observedIndex < 0
|
|
4925
|
+
? decision.snippet.slice(0, 160)
|
|
4926
|
+
: decision.snippet.slice(Math.max(0, observedIndex - 60), Math.min(decision.snippet.length, observedIndex + decision.observed.length + 60));
|
|
4927
|
+
const dedupeKey = this.store.hashContent([
|
|
4928
|
+
decision.targetCharacterId,
|
|
4929
|
+
normalizeRelationshipSearchText(decision.observed),
|
|
4930
|
+
decision.sourceType,
|
|
4931
|
+
decision.sourceId,
|
|
4932
|
+
decision.sourceVersion
|
|
4933
|
+
].join("|"));
|
|
4934
|
+
const review = this.store.createReviewItem(workId, {
|
|
4935
|
+
itemType: "character-name-variant",
|
|
4936
|
+
dedupeKey,
|
|
4937
|
+
severity: "medium",
|
|
4938
|
+
title: `疑似人物名错字:${decision.observed} → ${decision.targetName}`,
|
|
4939
|
+
description: `AI 判断来源“${decision.sourceTitle}”中的“${decision.observed}”可能指向人物“${decision.targetName}”。`,
|
|
4940
|
+
entityRefs: [{
|
|
4941
|
+
characterId: decision.targetCharacterId,
|
|
4942
|
+
sourceType: decision.sourceType,
|
|
4943
|
+
sourceId: decision.sourceId,
|
|
4944
|
+
sourceVersion: decision.sourceVersion
|
|
4945
|
+
}],
|
|
4946
|
+
evidence: [{
|
|
4947
|
+
sourceType: decision.sourceType,
|
|
4948
|
+
sourceId: decision.sourceId,
|
|
4949
|
+
sourceTitle: decision.sourceTitle,
|
|
4950
|
+
sourceVersion: decision.sourceVersion,
|
|
4951
|
+
observed: decision.observed,
|
|
4952
|
+
quote,
|
|
4953
|
+
confidence: decision.confidence,
|
|
4954
|
+
reason: decision.reason
|
|
4955
|
+
}],
|
|
4956
|
+
suggestion: `请核对“${decision.observed}”是否为“${decision.targetName}”的错别字;确认后再修改原文或登记别名。`,
|
|
4957
|
+
status: "pending"
|
|
4958
|
+
});
|
|
4959
|
+
reviewIds.add(String(review.id));
|
|
4960
|
+
}
|
|
4961
|
+
});
|
|
4962
|
+
sourceSelection.summary.reviewIds = [...reviewIds];
|
|
4963
|
+
}
|
|
4082
4964
|
const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
|
|
4083
4965
|
const relationshipResults = [...relationshipOutcomes.values()].map(({ action, relationship }) => {
|
|
4084
4966
|
const evidence = Array.isArray(relationship.evidence)
|
|
@@ -4159,6 +5041,7 @@ export class AiManager {
|
|
|
4159
5041
|
targetedEvidenceCount,
|
|
4160
5042
|
aggregationBatchCount,
|
|
4161
5043
|
replacedRelationshipCount,
|
|
5044
|
+
...(sourceSelection ? { sourceSelection: sourceSelection.summary } : {}),
|
|
4162
5045
|
callIds
|
|
4163
5046
|
};
|
|
4164
5047
|
}
|
|
@@ -4645,6 +5528,17 @@ export class AiManager {
|
|
|
4645
5528
|
this.assertAvailable(provider, model);
|
|
4646
5529
|
return { model, provider };
|
|
4647
5530
|
}
|
|
5531
|
+
analysisTaskModelPurpose(taskType) {
|
|
5532
|
+
if (taskType === "timeline-analysis")
|
|
5533
|
+
return "timeline-analysis";
|
|
5534
|
+
if (taskType === "relationship-analysis")
|
|
5535
|
+
return "relationship-analysis";
|
|
5536
|
+
if (taskType === "consistency-check")
|
|
5537
|
+
return "consistency-check";
|
|
5538
|
+
if (taskType === "chapter-analysis")
|
|
5539
|
+
return "chapter-analysis";
|
|
5540
|
+
return "book-analysis";
|
|
5541
|
+
}
|
|
4648
5542
|
configuredConcurrency(workId, taskType, modelId) {
|
|
4649
5543
|
const { provider } = this.resolveModel(workId, taskType, modelId);
|
|
4650
5544
|
return Math.round(clamp(numberValue(provider, "concurrency_limit") || 10, 1, 100));
|
|
@@ -4805,6 +5699,7 @@ export class AiManager {
|
|
|
4805
5699
|
scope: "platform",
|
|
4806
5700
|
name: stringValue(row, "name"),
|
|
4807
5701
|
baseUrl: stringValue(row, "base_url"),
|
|
5702
|
+
protocol: providerProtocol(row),
|
|
4808
5703
|
apiKey: stringValue(row, "key_hint"),
|
|
4809
5704
|
status: stringValue(row, "status"),
|
|
4810
5705
|
connectionStatus: stringValue(row, "connection_status"),
|