@musnows/scriverse 0.5.2 → 0.5.3
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 +8 -0
- package/dist/ai.js +950 -145
- package/dist/ai.js.map +1 -1
- package/dist/app.js +49 -6
- package/dist/app.js.map +1 -1
- package/dist/database.js +321 -0
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +60 -11
- package/dist/public/display-labels.js +1 -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/version.js +1 -1
- package/package.json +3 -2
package/dist/ai.js
CHANGED
|
@@ -4,6 +4,7 @@ import { logger, sanitizeError } from "./logger.js";
|
|
|
4
4
|
import { paginated, paginationSql } from "./pagination.js";
|
|
5
5
|
import { currentRequestActor } from "./request-context.js";
|
|
6
6
|
import { fetchSafeAiEndpoint } from "./security.js";
|
|
7
|
+
import { RELATIONSHIP_SEARCH_POLICY_VERSION, RelationshipApproximateMatchLimitError, findApproximateNameMatchesChunked, ftsPhrase, normalizeRelationshipSearchText, relationshipCharacterTokenText, relationshipCharacterTokens, relationshipPinyinTokenText, relationshipPinyinTokens } from "./relationship-search.js";
|
|
7
8
|
import { clamp, id, json, maskSecret, normalizeBaseUrl, now } from "./utils.js";
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
export function aiErrorForLog(error) {
|
|
@@ -19,6 +20,11 @@ export function aiErrorForLog(error) {
|
|
|
19
20
|
const allowedParameters = new Set(["temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty", "seed"]);
|
|
20
21
|
const DEFAULT_MAX_TOKENS = 32_000;
|
|
21
22
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
23
|
+
const RELATIONSHIP_MAX_FUZZY_REFERENCES = 32;
|
|
24
|
+
const RELATIONSHIP_MAX_FUZZY_SOURCES = 200;
|
|
25
|
+
const RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS = 4_000_000;
|
|
26
|
+
const RELATIONSHIP_MAX_FUZZY_MATCHES = 600;
|
|
27
|
+
const RELATIONSHIP_MAX_SOURCE_MATCHES = 256;
|
|
22
28
|
function isGeminiProviderOrModel(provider, model) {
|
|
23
29
|
const endpoint = stringValue(provider, "base_url").toLowerCase();
|
|
24
30
|
const modelId = stringValue(model, "model_id").toLowerCase();
|
|
@@ -850,18 +856,30 @@ export class AiManager {
|
|
|
850
856
|
vault;
|
|
851
857
|
fetchImpl;
|
|
852
858
|
validateOutboundUrl;
|
|
859
|
+
authorizeTaskRun;
|
|
853
860
|
contextBuilder;
|
|
854
861
|
taskControllers = new Map();
|
|
855
862
|
autoRunBatches = new Map();
|
|
856
863
|
autoRunTimers = new Map();
|
|
864
|
+
relationshipIndexBuilds = new Map();
|
|
865
|
+
relationshipSelectionCache = new Map();
|
|
866
|
+
relationshipSelectionBuilds = new Map();
|
|
867
|
+
relationshipIndexSerial = Promise.resolve();
|
|
868
|
+
relationshipIndexTimer = null;
|
|
869
|
+
relationshipIndexDisposed = false;
|
|
857
870
|
providerSchedules = new Map();
|
|
858
|
-
constructor(store, vault, fetchImpl = fetch, validateOutboundUrl) {
|
|
871
|
+
constructor(store, vault, fetchImpl = fetch, validateOutboundUrl, authorizeTaskRun) {
|
|
859
872
|
this.store = store;
|
|
860
873
|
this.vault = vault;
|
|
861
874
|
this.fetchImpl = fetchImpl;
|
|
862
875
|
this.validateOutboundUrl = validateOutboundUrl;
|
|
876
|
+
this.authorizeTaskRun = authorizeTaskRun;
|
|
863
877
|
this.contextBuilder = new ContextBuilder(store);
|
|
864
878
|
this.store.setAnalysisTaskQueuedHandler((workId) => this.scheduleAutoRun(workId));
|
|
879
|
+
this.relationshipIndexTimer = setTimeout(() => {
|
|
880
|
+
this.relationshipIndexTimer = null;
|
|
881
|
+
void this.schedulePendingRelationshipIndexes();
|
|
882
|
+
}, 0);
|
|
865
883
|
logger.info("ai.manager.ready");
|
|
866
884
|
}
|
|
867
885
|
resetAutoRunBatch(workId) {
|
|
@@ -913,6 +931,10 @@ export class AiManager {
|
|
|
913
931
|
clearTimeout(timer);
|
|
914
932
|
this.autoRunTimers.clear();
|
|
915
933
|
this.autoRunBatches.clear();
|
|
934
|
+
this.relationshipIndexDisposed = true;
|
|
935
|
+
if (this.relationshipIndexTimer)
|
|
936
|
+
clearTimeout(this.relationshipIndexTimer);
|
|
937
|
+
this.relationshipIndexTimer = null;
|
|
916
938
|
this.store.setAnalysisTaskQueuedHandler(null);
|
|
917
939
|
logger.info("ai.manager.disposed");
|
|
918
940
|
}
|
|
@@ -1091,14 +1113,27 @@ export class AiManager {
|
|
|
1091
1113
|
}
|
|
1092
1114
|
listPlatformModels() {
|
|
1093
1115
|
return this.store.db
|
|
1094
|
-
.all(
|
|
1095
|
-
|
|
1116
|
+
.all(`SELECT m.*, p.name AS provider_name, p.status AS provider_status, p.connection_status AS provider_connection_status
|
|
1117
|
+
FROM models m JOIN providers p ON p.id = m.provider_id
|
|
1118
|
+
WHERE p.work_id = ? ORDER BY p.created_at, m.created_at`, PLATFORM_AI_WORK_ID)
|
|
1119
|
+
.map((row) => ({
|
|
1120
|
+
...this.mapModel(row),
|
|
1121
|
+
providerName: stringValue(row, "provider_name"),
|
|
1122
|
+
providerStatus: stringValue(row, "provider_status"),
|
|
1123
|
+
providerConnectionStatus: stringValue(row, "provider_connection_status")
|
|
1124
|
+
}));
|
|
1096
1125
|
}
|
|
1097
1126
|
listPlatformModelsPage(pagination) {
|
|
1098
1127
|
const page = paginationSql(pagination);
|
|
1099
|
-
const rows = this.store.db.all(`SELECT m.*, p.name AS provider_name
|
|
1128
|
+
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
|
|
1129
|
+
FROM models m JOIN providers p ON p.id = m.provider_id
|
|
1100
1130
|
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) => ({
|
|
1131
|
+
return paginated(rows.map((row) => ({
|
|
1132
|
+
...this.mapModel(row),
|
|
1133
|
+
providerName: stringValue(row, "provider_name"),
|
|
1134
|
+
providerStatus: stringValue(row, "provider_status"),
|
|
1135
|
+
providerConnectionStatus: stringValue(row, "provider_connection_status")
|
|
1136
|
+
})), pagination);
|
|
1102
1137
|
}
|
|
1103
1138
|
listWorkModels(workId) {
|
|
1104
1139
|
this.store.getWork(workId);
|
|
@@ -1150,6 +1185,19 @@ export class AiManager {
|
|
|
1150
1185
|
model: this.getModel(stringValue(row, "model_id"))
|
|
1151
1186
|
})), pagination);
|
|
1152
1187
|
}
|
|
1188
|
+
createTask(workId, input) {
|
|
1189
|
+
this.store.getWork(workId);
|
|
1190
|
+
const modelPurpose = this.analysisTaskModelPurpose(input.taskType);
|
|
1191
|
+
const defaultRow = this.store.db.get("SELECT model_id FROM task_defaults WHERE work_id = ? AND task_type = ?", workId, modelPurpose);
|
|
1192
|
+
const modelId = input.modelId ?? (defaultRow ? stringValue(defaultRow, "model_id") : undefined);
|
|
1193
|
+
if (modelId)
|
|
1194
|
+
this.resolveModel(workId, modelPurpose, modelId);
|
|
1195
|
+
return this.store.createTask(workId, {
|
|
1196
|
+
taskType: input.taskType,
|
|
1197
|
+
...(input.scope ? { scope: input.scope } : {}),
|
|
1198
|
+
...(modelId ? { modelId } : {})
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1153
1201
|
async createSuggestion(input) {
|
|
1154
1202
|
const action = input.taskType === "continue" ? "append" : input.taskType === "polish" ? "replace-selection" : "note";
|
|
1155
1203
|
if (action === "replace-selection" && !input.scope.selection) {
|
|
@@ -1444,12 +1492,17 @@ export class AiManager {
|
|
|
1444
1492
|
}
|
|
1445
1493
|
};
|
|
1446
1494
|
}
|
|
1447
|
-
async runTask(taskId, modelId) {
|
|
1495
|
+
async runTask(taskId, modelId, actor) {
|
|
1448
1496
|
const task = this.store.getTask(taskId);
|
|
1497
|
+
this.authorizeTaskRun?.(task, actor);
|
|
1449
1498
|
const workId = String(task.workId);
|
|
1499
|
+
const taskModel = task.model && typeof task.model === "object" && !Array.isArray(task.model)
|
|
1500
|
+
? task.model
|
|
1501
|
+
: null;
|
|
1502
|
+
const selectedModelId = modelId ?? (typeof taskModel?.id === "string" ? taskModel.id : undefined);
|
|
1450
1503
|
const batch = this.getAutoRunBatch(workId);
|
|
1451
1504
|
const startedAt = process.hrtime.bigint();
|
|
1452
|
-
logger.info("ai.task.started", { taskId, workId, taskType: task.taskType, modelId:
|
|
1505
|
+
logger.info("ai.task.started", { taskId, workId, taskType: task.taskType, modelId: selectedModelId ?? null });
|
|
1453
1506
|
if (task.status !== "pending")
|
|
1454
1507
|
throw new AppError(409, "TASK_NOT_PENDING", "只有待执行任务可以运行");
|
|
1455
1508
|
if (!this.store.isTaskSourceCurrent(taskId)) {
|
|
@@ -1472,28 +1525,28 @@ export class AiManager {
|
|
|
1472
1525
|
const scope = task.scope;
|
|
1473
1526
|
let result;
|
|
1474
1527
|
if (taskType === "chapter-analysis") {
|
|
1475
|
-
result = await this.runChapterAnalysis(workId, scope,
|
|
1528
|
+
result = await this.runChapterAnalysis(workId, scope, selectedModelId, taskId);
|
|
1476
1529
|
}
|
|
1477
1530
|
else if (taskType === "character-extraction" || taskType === "character-summary") {
|
|
1478
|
-
result = await this.runCharacterExtraction(workId, scope,
|
|
1531
|
+
result = await this.runCharacterExtraction(workId, scope, selectedModelId, taskId);
|
|
1479
1532
|
}
|
|
1480
1533
|
else if (taskType === "character-identity-audit") {
|
|
1481
|
-
result = await this.runCharacterIdentityAudit(workId, scope,
|
|
1534
|
+
result = await this.runCharacterIdentityAudit(workId, scope, selectedModelId, taskId);
|
|
1482
1535
|
}
|
|
1483
1536
|
else if (taskType === "timeline-analysis") {
|
|
1484
|
-
result = await this.runTimelineAnalysis(workId, scope,
|
|
1537
|
+
result = await this.runTimelineAnalysis(workId, scope, selectedModelId, taskId);
|
|
1485
1538
|
}
|
|
1486
1539
|
else if (taskType === "relationship-analysis") {
|
|
1487
|
-
result = await this.runRelationshipAnalysis(workId, scope,
|
|
1540
|
+
result = await this.runRelationshipAnalysis(workId, scope, selectedModelId, taskId);
|
|
1488
1541
|
}
|
|
1489
1542
|
else if (taskType === "worldview-analysis") {
|
|
1490
|
-
result = await this.runWorldviewAnalysis(workId, scope,
|
|
1543
|
+
result = await this.runWorldviewAnalysis(workId, scope, selectedModelId, taskId);
|
|
1491
1544
|
}
|
|
1492
1545
|
else if (taskType === "setting-extraction") {
|
|
1493
|
-
result = await this.runSettingExtraction(workId, scope,
|
|
1546
|
+
result = await this.runSettingExtraction(workId, scope, selectedModelId, taskId);
|
|
1494
1547
|
}
|
|
1495
1548
|
else if (taskType === "consistency-check") {
|
|
1496
|
-
result = await this.runConsistencyCheck(workId, scope,
|
|
1549
|
+
result = await this.runConsistencyCheck(workId, scope, selectedModelId, taskId);
|
|
1497
1550
|
}
|
|
1498
1551
|
else {
|
|
1499
1552
|
const generated = await this.generate({
|
|
@@ -1503,7 +1556,7 @@ export class AiManager {
|
|
|
1503
1556
|
instruction: "请基于上下文完成分析,给出有原文依据的中文结论。",
|
|
1504
1557
|
scope,
|
|
1505
1558
|
signal: taskController.signal,
|
|
1506
|
-
...(
|
|
1559
|
+
...(selectedModelId ? { modelId: selectedModelId } : {})
|
|
1507
1560
|
});
|
|
1508
1561
|
result = { content: generated.content, callId: generated.callId };
|
|
1509
1562
|
}
|
|
@@ -3297,8 +3350,651 @@ export class AiManager {
|
|
|
3297
3350
|
}
|
|
3298
3351
|
};
|
|
3299
3352
|
}
|
|
3300
|
-
|
|
3301
|
-
|
|
3353
|
+
async schedulePendingRelationshipIndexes() {
|
|
3354
|
+
if (this.relationshipIndexDisposed)
|
|
3355
|
+
return;
|
|
3356
|
+
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));
|
|
3357
|
+
await Promise.allSettled(workIds.map((workId) => this.ensureRelationshipSearchIndex(workId)));
|
|
3358
|
+
}
|
|
3359
|
+
ensureRelationshipSearchIndex(workId) {
|
|
3360
|
+
const existing = this.relationshipIndexBuilds.get(workId);
|
|
3361
|
+
if (existing)
|
|
3362
|
+
return existing;
|
|
3363
|
+
const build = this.relationshipIndexSerial.then(async () => this.drainRelationshipSearchIndex(workId));
|
|
3364
|
+
this.relationshipIndexSerial = build.then(() => undefined, () => undefined);
|
|
3365
|
+
this.relationshipIndexBuilds.set(workId, build);
|
|
3366
|
+
void build.finally(() => {
|
|
3367
|
+
if (this.relationshipIndexBuilds.get(workId) === build)
|
|
3368
|
+
this.relationshipIndexBuilds.delete(workId);
|
|
3369
|
+
}).catch(() => undefined);
|
|
3370
|
+
return build;
|
|
3371
|
+
}
|
|
3372
|
+
async drainRelationshipSearchIndex(workId) {
|
|
3373
|
+
if (this.relationshipIndexDisposed)
|
|
3374
|
+
return 0;
|
|
3375
|
+
const timestamp = now();
|
|
3376
|
+
this.store.db.run(`INSERT INTO relationship_source_index_state(work_id, status, generation, error, updated_at)
|
|
3377
|
+
VALUES (?, 'building', 0, '', ?)
|
|
3378
|
+
ON CONFLICT(work_id) DO UPDATE SET status = 'building', error = '', updated_at = excluded.updated_at`, workId, timestamp);
|
|
3379
|
+
let processed = 0;
|
|
3380
|
+
try {
|
|
3381
|
+
while (!this.relationshipIndexDisposed) {
|
|
3382
|
+
const queued = this.store.db.all(`SELECT source_type, source_id, queued_at FROM relationship_source_index_queue
|
|
3383
|
+
WHERE work_id = ? ORDER BY queued_at, source_type, source_id LIMIT 50`, workId);
|
|
3384
|
+
if (queued.length === 0)
|
|
3385
|
+
break;
|
|
3386
|
+
for (const item of queued) {
|
|
3387
|
+
const sourceType = String(item.source_type);
|
|
3388
|
+
const sourceId = String(item.source_id);
|
|
3389
|
+
const queuedAt = String(item.queued_at);
|
|
3390
|
+
this.store.db.transaction(() => {
|
|
3391
|
+
if (sourceType === "chapter")
|
|
3392
|
+
this.indexRelationshipChapter(workId, sourceId);
|
|
3393
|
+
else
|
|
3394
|
+
this.indexRelationshipSettingSource(workId, sourceType, sourceId);
|
|
3395
|
+
this.store.db.run(`DELETE FROM relationship_source_index_queue
|
|
3396
|
+
WHERE work_id = ? AND source_type = ? AND source_id = ? AND queued_at = ?`, workId, sourceType, sourceId, queuedAt);
|
|
3397
|
+
});
|
|
3398
|
+
processed += 1;
|
|
3399
|
+
}
|
|
3400
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
3401
|
+
}
|
|
3402
|
+
if (this.relationshipIndexDisposed) {
|
|
3403
|
+
this.store.db.run("UPDATE relationship_source_index_state SET status = 'queued', updated_at = ? WHERE work_id = ?", now(), workId);
|
|
3404
|
+
return 0;
|
|
3405
|
+
}
|
|
3406
|
+
this.store.db.run(`UPDATE relationship_source_index_state
|
|
3407
|
+
SET status = 'ready', generation = generation + ?, error = '', updated_at = ? WHERE work_id = ?`, processed > 0 ? 1 : 0, now(), workId);
|
|
3408
|
+
const generation = Number(this.store.db.get("SELECT generation FROM relationship_source_index_state WHERE work_id = ?", workId)?.generation ?? 0);
|
|
3409
|
+
logger.info("relationship.search_index.ready", { workId, generation, processed });
|
|
3410
|
+
return generation;
|
|
3411
|
+
}
|
|
3412
|
+
catch (error) {
|
|
3413
|
+
const message = error instanceof Error ? error.message : "索引构建失败";
|
|
3414
|
+
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);
|
|
3415
|
+
logger.error("relationship.search_index.failed", { workId, processed, error: sanitizeError(error) });
|
|
3416
|
+
throw error;
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
indexRelationshipChapter(workId, chapterId) {
|
|
3420
|
+
const chapter = this.store.db.get("SELECT id FROM chapters WHERE id = ? AND work_id = ?", chapterId, workId);
|
|
3421
|
+
if (!chapter)
|
|
3422
|
+
return;
|
|
3423
|
+
const paragraphs = this.store.db.all("SELECT id, search_content FROM chapter_paragraph_search WHERE chapter_id = ? ORDER BY paragraph_order", chapterId);
|
|
3424
|
+
for (const paragraph of paragraphs) {
|
|
3425
|
+
const rowId = Number(paragraph.id);
|
|
3426
|
+
this.store.db.run("DELETE FROM chapter_paragraph_pinyin_fts WHERE rowid = ?", rowId);
|
|
3427
|
+
this.store.db.run("INSERT INTO chapter_paragraph_pinyin_fts(rowid, pinyin_tokens) VALUES (?, ?)", rowId, relationshipPinyinTokenText(String(paragraph.search_content)));
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
indexRelationshipSettingSource(workId, sourceType, sourceId) {
|
|
3431
|
+
const materialized = this.relationshipSettingSource(workId, sourceType, sourceId);
|
|
3432
|
+
const existing = this.store.db.get("SELECT id FROM relationship_source_search WHERE work_id = ? AND source_type = ? AND source_id = ?", workId, sourceType, sourceId);
|
|
3433
|
+
if (!materialized) {
|
|
3434
|
+
if (existing)
|
|
3435
|
+
this.store.db.run("DELETE FROM relationship_source_search WHERE id = ?", Number(existing.id));
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3438
|
+
const searchable = `${materialized.title}\n${materialized.content}`;
|
|
3439
|
+
const contentHash = this.store.hashContent(searchable);
|
|
3440
|
+
let rowId = Number(existing?.id ?? 0);
|
|
3441
|
+
if (existing) {
|
|
3442
|
+
this.store.db.run(`UPDATE relationship_source_search SET source_version = ?, content_hash = ?, updated_at = ? WHERE id = ?`, materialized.version, contentHash, now(), rowId);
|
|
3443
|
+
this.store.db.run("DELETE FROM relationship_source_exact_fts WHERE rowid = ?", rowId);
|
|
3444
|
+
this.store.db.run("DELETE FROM relationship_source_pinyin_fts WHERE rowid = ?", rowId);
|
|
3445
|
+
}
|
|
3446
|
+
else {
|
|
3447
|
+
rowId = Number(this.store.db.run(`INSERT INTO relationship_source_search(work_id, source_type, source_id, source_version, content_hash, updated_at)
|
|
3448
|
+
VALUES (?, ?, ?, ?, ?, ?)`, workId, sourceType, sourceId, materialized.version, contentHash, now()).lastInsertRowid);
|
|
3449
|
+
}
|
|
3450
|
+
this.store.db.run("INSERT INTO relationship_source_exact_fts(rowid, character_tokens) VALUES (?, ?)", rowId, relationshipCharacterTokenText(searchable));
|
|
3451
|
+
this.store.db.run("INSERT INTO relationship_source_pinyin_fts(rowid, pinyin_tokens) VALUES (?, ?)", rowId, relationshipPinyinTokenText(searchable));
|
|
3452
|
+
}
|
|
3453
|
+
relationshipScopeChapterIds(workId, scope) {
|
|
3454
|
+
if (scope.type === "settings")
|
|
3455
|
+
return new Set();
|
|
3456
|
+
if (scope.type === "chapter") {
|
|
3457
|
+
if (!scope.chapterId)
|
|
3458
|
+
throw new AppError(400, "CHAPTER_REQUIRED", "分析范围缺少章节标识");
|
|
3459
|
+
const chapter = this.store.getChapter(scope.chapterId);
|
|
3460
|
+
if (String(chapter.workId) !== workId)
|
|
3461
|
+
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
3462
|
+
return new Set([scope.chapterId]);
|
|
3463
|
+
}
|
|
3464
|
+
if (scope.type === "volume") {
|
|
3465
|
+
if (!scope.volumeId)
|
|
3466
|
+
throw new AppError(400, "VOLUME_REQUIRED", "分析范围缺少分卷标识");
|
|
3467
|
+
const volume = this.store.getVolume(scope.volumeId);
|
|
3468
|
+
if (String(volume.workId) !== workId)
|
|
3469
|
+
throw new AppError(400, "VOLUME_WORK_MISMATCH", "分卷不属于当前作品");
|
|
3470
|
+
return new Set(this.store.db.all(`SELECT id FROM chapters WHERE work_id = ? AND volume_id = ?
|
|
3471
|
+
AND excluded_from_analysis = 0 AND chapter_type <> '作者的话'`, workId, scope.volumeId).map((row) => String(row.id)));
|
|
3472
|
+
}
|
|
3473
|
+
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)));
|
|
3474
|
+
}
|
|
3475
|
+
relationshipIndexedSource(workId, sourceType, sourceId) {
|
|
3476
|
+
if (sourceType === "chapter") {
|
|
3477
|
+
try {
|
|
3478
|
+
const chapter = this.store.getChapter(sourceId);
|
|
3479
|
+
if (String(chapter.workId) !== workId)
|
|
3480
|
+
return null;
|
|
3481
|
+
return {
|
|
3482
|
+
sourceType,
|
|
3483
|
+
sourceId,
|
|
3484
|
+
title: String(chapter.title),
|
|
3485
|
+
content: String(chapter.content),
|
|
3486
|
+
version: String(chapter.versionNo)
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
3489
|
+
catch {
|
|
3490
|
+
return null;
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
const source = this.relationshipSettingSource(workId, sourceType, sourceId);
|
|
3494
|
+
return source ? {
|
|
3495
|
+
sourceType,
|
|
3496
|
+
sourceId,
|
|
3497
|
+
title: source.title,
|
|
3498
|
+
content: source.content,
|
|
3499
|
+
version: source.version
|
|
3500
|
+
} : null;
|
|
3501
|
+
}
|
|
3502
|
+
relationshipIndexedSourceKey(sourceType, sourceId) {
|
|
3503
|
+
return `${sourceType}:${sourceId}`;
|
|
3504
|
+
}
|
|
3505
|
+
relationshipIndexedSourceRef(key) {
|
|
3506
|
+
const separator = key.indexOf(":");
|
|
3507
|
+
return separator < 0
|
|
3508
|
+
? { sourceType: "setting", sourceId: key }
|
|
3509
|
+
: { sourceType: key.slice(0, separator), sourceId: key.slice(separator + 1) };
|
|
3510
|
+
}
|
|
3511
|
+
relationshipChapterExactMatches(workId, reference) {
|
|
3512
|
+
const normalized = normalizeRelationshipSearchText(reference).trim();
|
|
3513
|
+
if (!normalized)
|
|
3514
|
+
return [];
|
|
3515
|
+
const rows = [...normalized].length < 3
|
|
3516
|
+
? this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_short_terms term
|
|
3517
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = term.paragraph_id
|
|
3518
|
+
WHERE paragraph.work_id = ? AND term.term = ?`, workId, normalized)
|
|
3519
|
+
: this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_search_fts
|
|
3520
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_search_fts.rowid
|
|
3521
|
+
WHERE paragraph.work_id = ? AND chapter_paragraph_search_fts MATCH ?`, workId, `"${normalized.replaceAll('"', '""')}"`);
|
|
3522
|
+
return rows.map((row) => String(row.chapter_id));
|
|
3523
|
+
}
|
|
3524
|
+
relationshipSettingExactMatches(workId, reference) {
|
|
3525
|
+
const phrase = ftsPhrase(relationshipCharacterTokens(reference));
|
|
3526
|
+
return this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_exact_fts
|
|
3527
|
+
JOIN relationship_source_search source ON source.id = relationship_source_exact_fts.rowid
|
|
3528
|
+
WHERE source.work_id = ? AND relationship_source_exact_fts MATCH ?
|
|
3529
|
+
AND NOT (source.source_type = 'review' AND EXISTS (
|
|
3530
|
+
SELECT 1 FROM review_items review
|
|
3531
|
+
WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
|
|
3532
|
+
))`, workId, phrase).map((row) => this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
|
|
3533
|
+
}
|
|
3534
|
+
relationshipFuzzyIndexMatches(workId, reference, includeSettings, scope) {
|
|
3535
|
+
const result = new Set();
|
|
3536
|
+
const characterTokens = [...new Set(relationshipCharacterTokens(reference))];
|
|
3537
|
+
const pinyinTokens = [...new Set(relationshipPinyinTokens(reference))];
|
|
3538
|
+
const score = new Map();
|
|
3539
|
+
const add = (key) => {
|
|
3540
|
+
score.set(key, (score.get(key) ?? 0) + 1);
|
|
3541
|
+
};
|
|
3542
|
+
const chapterScope = scope.type === "chapter"
|
|
3543
|
+
? { sql: "AND paragraph.chapter_id = ?", params: [scope.chapterId ?? ""] }
|
|
3544
|
+
: scope.type === "volume"
|
|
3545
|
+
? {
|
|
3546
|
+
sql: `AND EXISTS (
|
|
3547
|
+
SELECT 1 FROM chapters chapter WHERE chapter.id = paragraph.chapter_id
|
|
3548
|
+
AND chapter.volume_id = ? AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'
|
|
3549
|
+
)`,
|
|
3550
|
+
params: [scope.volumeId ?? ""]
|
|
3551
|
+
}
|
|
3552
|
+
: {
|
|
3553
|
+
sql: `AND EXISTS (
|
|
3554
|
+
SELECT 1 FROM chapters chapter WHERE chapter.id = paragraph.chapter_id
|
|
3555
|
+
AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'
|
|
3556
|
+
)`,
|
|
3557
|
+
params: []
|
|
3558
|
+
};
|
|
3559
|
+
const includeChapters = scope.type !== "settings";
|
|
3560
|
+
const pinyinPhrase = ftsPhrase(relationshipPinyinTokens(reference));
|
|
3561
|
+
if (includeChapters) {
|
|
3562
|
+
for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_pinyin_fts
|
|
3563
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_pinyin_fts.rowid
|
|
3564
|
+
WHERE paragraph.work_id = ? AND chapter_paragraph_pinyin_fts MATCH ? ${chapterScope.sql}
|
|
3565
|
+
LIMIT 201`, workId, pinyinPhrase, ...chapterScope.params))
|
|
3566
|
+
result.add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
|
|
3567
|
+
}
|
|
3568
|
+
if (includeSettings) {
|
|
3569
|
+
for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_pinyin_fts
|
|
3570
|
+
JOIN relationship_source_search source ON source.id = relationship_source_pinyin_fts.rowid
|
|
3571
|
+
WHERE source.work_id = ? AND relationship_source_pinyin_fts MATCH ?
|
|
3572
|
+
AND NOT (source.source_type = 'review' AND EXISTS (
|
|
3573
|
+
SELECT 1 FROM review_items review
|
|
3574
|
+
WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
|
|
3575
|
+
))
|
|
3576
|
+
LIMIT 201`, workId, pinyinPhrase))
|
|
3577
|
+
result.add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
|
|
3578
|
+
}
|
|
3579
|
+
const normalizedCharacters = [...normalizeRelationshipSearchText(reference).trim()];
|
|
3580
|
+
if (includeChapters) {
|
|
3581
|
+
for (const character of [...new Set(normalizedCharacters)]) {
|
|
3582
|
+
for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_short_terms term
|
|
3583
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = term.paragraph_id
|
|
3584
|
+
WHERE paragraph.work_id = ? AND term.term = ? ${chapterScope.sql}
|
|
3585
|
+
LIMIT 201`, workId, character, ...chapterScope.params))
|
|
3586
|
+
add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
|
|
3587
|
+
}
|
|
3588
|
+
for (const token of pinyinTokens) {
|
|
3589
|
+
for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_pinyin_fts
|
|
3590
|
+
JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_pinyin_fts.rowid
|
|
3591
|
+
WHERE paragraph.work_id = ? AND chapter_paragraph_pinyin_fts MATCH ? ${chapterScope.sql}
|
|
3592
|
+
LIMIT 201`, workId, token, ...chapterScope.params))
|
|
3593
|
+
add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
if (includeSettings) {
|
|
3597
|
+
for (const token of characterTokens) {
|
|
3598
|
+
for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_exact_fts
|
|
3599
|
+
JOIN relationship_source_search source ON source.id = relationship_source_exact_fts.rowid
|
|
3600
|
+
WHERE source.work_id = ? AND relationship_source_exact_fts MATCH ?
|
|
3601
|
+
AND NOT (source.source_type = 'review' AND EXISTS (
|
|
3602
|
+
SELECT 1 FROM review_items review
|
|
3603
|
+
WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
|
|
3604
|
+
))
|
|
3605
|
+
LIMIT 201`, workId, token))
|
|
3606
|
+
add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
|
|
3607
|
+
}
|
|
3608
|
+
for (const token of pinyinTokens) {
|
|
3609
|
+
for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_pinyin_fts
|
|
3610
|
+
JOIN relationship_source_search source ON source.id = relationship_source_pinyin_fts.rowid
|
|
3611
|
+
WHERE source.work_id = ? AND relationship_source_pinyin_fts MATCH ?
|
|
3612
|
+
AND NOT (source.source_type = 'review' AND EXISTS (
|
|
3613
|
+
SELECT 1 FROM review_items review
|
|
3614
|
+
WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
|
|
3615
|
+
))
|
|
3616
|
+
LIMIT 201`, workId, token))
|
|
3617
|
+
add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
const threshold = Math.max(1, [...normalizeRelationshipSearchText(reference).trim()].length - 1);
|
|
3621
|
+
for (const [key, count] of score)
|
|
3622
|
+
if (count >= threshold)
|
|
3623
|
+
result.add(key);
|
|
3624
|
+
return result;
|
|
3625
|
+
}
|
|
3626
|
+
relationshipIdentityAnchors(workId, character) {
|
|
3627
|
+
const characterId = String(character.id);
|
|
3628
|
+
const relatedIds = new Set();
|
|
3629
|
+
for (const relationship of this.store.listRelationships(workId)) {
|
|
3630
|
+
if (String(relationship.fromCharacterId) === characterId)
|
|
3631
|
+
relatedIds.add(String(relationship.toCharacterId));
|
|
3632
|
+
if (String(relationship.toCharacterId) === characterId)
|
|
3633
|
+
relatedIds.add(String(relationship.fromCharacterId));
|
|
3634
|
+
}
|
|
3635
|
+
const anchors = [
|
|
3636
|
+
String(character.code ?? ""),
|
|
3637
|
+
String(character.species ?? ""),
|
|
3638
|
+
String(character.race?.name ?? ""),
|
|
3639
|
+
...(Array.isArray(character.organizations) ? character.organizations.map((item) => String(item.name ?? "")) : []),
|
|
3640
|
+
...[...relatedIds].flatMap((relatedId) => {
|
|
3641
|
+
try {
|
|
3642
|
+
const related = this.store.getCharacter(relatedId);
|
|
3643
|
+
return [String(related.name), ...related.aliases];
|
|
3644
|
+
}
|
|
3645
|
+
catch {
|
|
3646
|
+
return [];
|
|
3647
|
+
}
|
|
3648
|
+
})
|
|
3649
|
+
].map((value) => normalizeRelationshipSearchText(value).trim())
|
|
3650
|
+
.filter((value) => [...value].length >= 2);
|
|
3651
|
+
return [...new Set(anchors)];
|
|
3652
|
+
}
|
|
3653
|
+
async localRelationshipSourceSelection(workId, scope, characters, selectedCharacterIds, generation) {
|
|
3654
|
+
const targetCharacters = characters.filter((character) => selectedCharacterIds.has(String(character.id)));
|
|
3655
|
+
const cacheKey = JSON.stringify({
|
|
3656
|
+
workId,
|
|
3657
|
+
scope: {
|
|
3658
|
+
type: scope.type,
|
|
3659
|
+
chapterId: scope.chapterId ?? null,
|
|
3660
|
+
volumeId: scope.volumeId ?? null,
|
|
3661
|
+
includeAllSettings: scope.includeAllSettings === true
|
|
3662
|
+
},
|
|
3663
|
+
targets: targetCharacters.map((character) => ({ id: character.id, versionNo: character.versionNo })),
|
|
3664
|
+
generation,
|
|
3665
|
+
policyVersion: RELATIONSHIP_SEARCH_POLICY_VERSION
|
|
3666
|
+
});
|
|
3667
|
+
const cached = this.relationshipSelectionCache.get(cacheKey);
|
|
3668
|
+
if (cached)
|
|
3669
|
+
return cached;
|
|
3670
|
+
const existingBuild = this.relationshipSelectionBuilds.get(cacheKey);
|
|
3671
|
+
if (existingBuild)
|
|
3672
|
+
return existingBuild;
|
|
3673
|
+
const build = (async () => {
|
|
3674
|
+
const allowedChapterIds = this.relationshipScopeChapterIds(workId, scope);
|
|
3675
|
+
const includeSettings = scope.type === "settings" || scope.includeAllSettings === true;
|
|
3676
|
+
const exactKeys = new Set();
|
|
3677
|
+
const candidates = [];
|
|
3678
|
+
const candidateKeys = new Set();
|
|
3679
|
+
const candidateOccurrences = new Map();
|
|
3680
|
+
const loadedSources = new Map();
|
|
3681
|
+
const knownCharacterReferences = new Set(characters.flatMap((character) => [
|
|
3682
|
+
String(character.name),
|
|
3683
|
+
...(Array.isArray(character.aliases) ? character.aliases.map(String) : [])
|
|
3684
|
+
]).map((value) => normalizeRelationshipSearchText(value).trim()).filter(Boolean));
|
|
3685
|
+
for (const character of targetCharacters) {
|
|
3686
|
+
const targetCharacterId = String(character.id);
|
|
3687
|
+
const exactReferences = [...new Set([String(character.name), ...character.aliases].map((item) => item.trim()).filter(Boolean))];
|
|
3688
|
+
const fuzzyReferenceCount = exactReferences.filter((reference) => [...normalizeRelationshipSearchText(reference).trim()].length >= 2).length;
|
|
3689
|
+
if (fuzzyReferenceCount > RELATIONSHIP_MAX_FUZZY_REFERENCES) {
|
|
3690
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "人物名称和别名过多,无法在安全预算内完成疑似写法匹配", {
|
|
3691
|
+
characterId: targetCharacterId,
|
|
3692
|
+
fuzzyReferenceCount,
|
|
3693
|
+
maximumFuzzyReferences: RELATIONSHIP_MAX_FUZZY_REFERENCES
|
|
3694
|
+
});
|
|
3695
|
+
}
|
|
3696
|
+
const normalizedExactReferences = new Set(exactReferences.map((item) => normalizeRelationshipSearchText(item).trim()));
|
|
3697
|
+
const anchors = this.relationshipIdentityAnchors(workId, character);
|
|
3698
|
+
const anchorKeys = new Set();
|
|
3699
|
+
for (const anchor of anchors) {
|
|
3700
|
+
for (const chapterId of this.relationshipChapterExactMatches(workId, anchor)) {
|
|
3701
|
+
if (allowedChapterIds.has(chapterId))
|
|
3702
|
+
anchorKeys.add(this.relationshipIndexedSourceKey("chapter", chapterId));
|
|
3703
|
+
}
|
|
3704
|
+
if (includeSettings)
|
|
3705
|
+
for (const key of this.relationshipSettingExactMatches(workId, anchor))
|
|
3706
|
+
anchorKeys.add(key);
|
|
3707
|
+
}
|
|
3708
|
+
const targetIndexCandidateKeys = new Set();
|
|
3709
|
+
const targetFuzzySourceKeys = new Set();
|
|
3710
|
+
let fuzzyScanCharacters = 0;
|
|
3711
|
+
let fuzzyMatchCount = 0;
|
|
3712
|
+
for (const reference of exactReferences) {
|
|
3713
|
+
for (const chapterId of this.relationshipChapterExactMatches(workId, reference)) {
|
|
3714
|
+
if (allowedChapterIds.has(chapterId))
|
|
3715
|
+
exactKeys.add(this.relationshipIndexedSourceKey("chapter", chapterId));
|
|
3716
|
+
}
|
|
3717
|
+
if (includeSettings)
|
|
3718
|
+
for (const key of this.relationshipSettingExactMatches(workId, reference))
|
|
3719
|
+
exactKeys.add(key);
|
|
3720
|
+
const referenceLength = [...normalizeRelationshipSearchText(reference).trim()].length;
|
|
3721
|
+
if (referenceLength < 2)
|
|
3722
|
+
continue;
|
|
3723
|
+
const fuzzyIndexKeys = referenceLength === 2
|
|
3724
|
+
? anchorKeys
|
|
3725
|
+
: this.relationshipFuzzyIndexMatches(workId, reference, includeSettings, scope);
|
|
3726
|
+
for (const key of fuzzyIndexKeys) {
|
|
3727
|
+
const ref = this.relationshipIndexedSourceRef(key);
|
|
3728
|
+
if (ref.sourceType === "chapter" && !allowedChapterIds.has(ref.sourceId))
|
|
3729
|
+
continue;
|
|
3730
|
+
if (ref.sourceType !== "chapter" && !includeSettings)
|
|
3731
|
+
continue;
|
|
3732
|
+
targetIndexCandidateKeys.add(key);
|
|
3733
|
+
if (targetIndexCandidateKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES) {
|
|
3734
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名来源过多,请补充人物别名或身份资料后重试", {
|
|
3735
|
+
characterId: targetCharacterId,
|
|
3736
|
+
candidateCount: targetIndexCandidateKeys.size,
|
|
3737
|
+
maximum: RELATIONSHIP_MAX_FUZZY_SOURCES
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
let indexed = loadedSources.get(key);
|
|
3741
|
+
if (!indexed) {
|
|
3742
|
+
const loaded = this.relationshipIndexedSource(workId, ref.sourceType, ref.sourceId);
|
|
3743
|
+
if (!loaded)
|
|
3744
|
+
continue;
|
|
3745
|
+
indexed = loaded;
|
|
3746
|
+
loadedSources.set(key, indexed);
|
|
3747
|
+
}
|
|
3748
|
+
if (indexed.sourceType === "review" && indexed.content.includes('"itemType": "character-name-variant"'))
|
|
3749
|
+
continue;
|
|
3750
|
+
const searchable = `${indexed.title}\n${indexed.content}`;
|
|
3751
|
+
const normalizedSearchable = normalizeRelationshipSearchText(searchable);
|
|
3752
|
+
fuzzyScanCharacters += normalizedSearchable.length;
|
|
3753
|
+
if (fuzzyScanCharacters > RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS) {
|
|
3754
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名待核对文本过多,请缩小分析范围或补充人物别名", {
|
|
3755
|
+
characterId: targetCharacterId,
|
|
3756
|
+
scannedCharacters: fuzzyScanCharacters,
|
|
3757
|
+
maximumScannedCharacters: RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS
|
|
3758
|
+
});
|
|
3759
|
+
}
|
|
3760
|
+
const referenceCharacters = [...normalizeRelationshipSearchText(reference).trim()];
|
|
3761
|
+
let approximateMatches;
|
|
3762
|
+
try {
|
|
3763
|
+
approximateMatches = await findApproximateNameMatchesChunked(searchable, reference, 24, knownCharacterReferences, RELATIONSHIP_MAX_SOURCE_MATCHES);
|
|
3764
|
+
}
|
|
3765
|
+
catch (error) {
|
|
3766
|
+
if (!(error instanceof RelationshipApproximateMatchLimitError))
|
|
3767
|
+
throw error;
|
|
3768
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "单个来源中的疑似人物名写法过多,请缩小分析范围或补充人物别名", {
|
|
3769
|
+
characterId: targetCharacterId,
|
|
3770
|
+
sourceType: indexed.sourceType,
|
|
3771
|
+
sourceId: indexed.sourceId,
|
|
3772
|
+
maximumSourceMatches: error.maximumCandidates
|
|
3773
|
+
});
|
|
3774
|
+
}
|
|
3775
|
+
for (const match of approximateMatches) {
|
|
3776
|
+
if (normalizedExactReferences.has(normalizeRelationshipSearchText(match.observed).trim()))
|
|
3777
|
+
continue;
|
|
3778
|
+
if (referenceLength === 2
|
|
3779
|
+
&& !anchors.some((anchor) => normalizedSearchable.includes(anchor)))
|
|
3780
|
+
continue;
|
|
3781
|
+
const occurrenceKey = [targetCharacterId, indexed.sourceType, indexed.sourceId, match.observed].join("|");
|
|
3782
|
+
const occurrenceCount = candidateOccurrences.get(occurrenceKey) ?? 0;
|
|
3783
|
+
if (occurrenceCount >= 3)
|
|
3784
|
+
continue;
|
|
3785
|
+
const candidateKey = [targetCharacterId, indexed.sourceType, indexed.sourceId, match.observed, reference, match.start].join("|");
|
|
3786
|
+
if (candidateKeys.has(candidateKey))
|
|
3787
|
+
continue;
|
|
3788
|
+
candidateKeys.add(candidateKey);
|
|
3789
|
+
candidateOccurrences.set(occurrenceKey, occurrenceCount + 1);
|
|
3790
|
+
fuzzyMatchCount += 1;
|
|
3791
|
+
if (fuzzyMatchCount > RELATIONSHIP_MAX_FUZZY_MATCHES) {
|
|
3792
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名写法过多,请缩小分析范围或补充人物别名", {
|
|
3793
|
+
characterId: targetCharacterId,
|
|
3794
|
+
fuzzyMatchCount,
|
|
3795
|
+
maximumFuzzyMatches: RELATIONSHIP_MAX_FUZZY_MATCHES
|
|
3796
|
+
});
|
|
3797
|
+
}
|
|
3798
|
+
targetFuzzySourceKeys.add(key);
|
|
3799
|
+
const snippetStart = Math.max(0, match.utf16Start - 240);
|
|
3800
|
+
const snippetEnd = Math.min(normalizedSearchable.length, match.utf16End + 240);
|
|
3801
|
+
candidates.push({
|
|
3802
|
+
key: candidateKey,
|
|
3803
|
+
targetCharacterId,
|
|
3804
|
+
targetName: String(character.name),
|
|
3805
|
+
reference,
|
|
3806
|
+
sourceType: indexed.sourceType,
|
|
3807
|
+
sourceId: indexed.sourceId,
|
|
3808
|
+
sourceTitle: indexed.title,
|
|
3809
|
+
sourceVersion: indexed.version,
|
|
3810
|
+
observed: match.observed,
|
|
3811
|
+
snippet: normalizedSearchable.slice(snippetStart, snippetEnd),
|
|
3812
|
+
characterDistance: match.characterDistance,
|
|
3813
|
+
pinyinDistance: match.pinyinDistance
|
|
3814
|
+
});
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
if (targetFuzzySourceKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES) {
|
|
3819
|
+
throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名来源过多,请补充人物别名或身份资料后重试", {
|
|
3820
|
+
characterId: targetCharacterId,
|
|
3821
|
+
candidateCount: targetFuzzySourceKeys.size,
|
|
3822
|
+
maximum: RELATIONSHIP_MAX_FUZZY_SOURCES
|
|
3823
|
+
});
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
const result = { generation, exactKeys: [...exactKeys], candidates };
|
|
3827
|
+
this.relationshipSelectionCache.set(cacheKey, result);
|
|
3828
|
+
if (this.relationshipSelectionCache.size > 128) {
|
|
3829
|
+
const oldest = this.relationshipSelectionCache.keys().next().value;
|
|
3830
|
+
if (typeof oldest === "string")
|
|
3831
|
+
this.relationshipSelectionCache.delete(oldest);
|
|
3832
|
+
}
|
|
3833
|
+
return result;
|
|
3834
|
+
})();
|
|
3835
|
+
this.relationshipSelectionBuilds.set(cacheKey, build);
|
|
3836
|
+
try {
|
|
3837
|
+
return await build;
|
|
3838
|
+
}
|
|
3839
|
+
finally {
|
|
3840
|
+
if (this.relationshipSelectionBuilds.get(cacheKey) === build)
|
|
3841
|
+
this.relationshipSelectionBuilds.delete(cacheKey);
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
async verifyRelationshipVariantCandidates(workId, candidates, modelId, taskId) {
|
|
3845
|
+
if (candidates.length === 0)
|
|
3846
|
+
return { decisions: [], callIds: [] };
|
|
3847
|
+
const batches = [];
|
|
3848
|
+
let batch = [];
|
|
3849
|
+
let batchLength = 0;
|
|
3850
|
+
for (const candidate of candidates) {
|
|
3851
|
+
const length = JSON.stringify(candidate).length;
|
|
3852
|
+
if (batch.length > 0 && batchLength + length > 12_000) {
|
|
3853
|
+
batches.push(batch);
|
|
3854
|
+
batch = [];
|
|
3855
|
+
batchLength = 0;
|
|
3856
|
+
}
|
|
3857
|
+
batch.push(candidate);
|
|
3858
|
+
batchLength += length;
|
|
3859
|
+
}
|
|
3860
|
+
if (batch.length > 0)
|
|
3861
|
+
batches.push(batch);
|
|
3862
|
+
const decisions = [];
|
|
3863
|
+
const callIds = [];
|
|
3864
|
+
try {
|
|
3865
|
+
for (const candidateBatch of batches) {
|
|
3866
|
+
const snippets = candidateBatch.map((candidate) => {
|
|
3867
|
+
const tag = candidate.sourceType === "chapter" ? "CHAPTER" : "SETTING";
|
|
3868
|
+
return [
|
|
3869
|
+
`<${tag} id="${candidate.sourceId.replaceAll('"', "'")}" title="${candidate.sourceTitle.replaceAll('"', "'")}">`,
|
|
3870
|
+
JSON.stringify({
|
|
3871
|
+
key: candidate.key,
|
|
3872
|
+
targetCharacterId: candidate.targetCharacterId,
|
|
3873
|
+
targetName: candidate.targetName,
|
|
3874
|
+
registeredReference: candidate.reference,
|
|
3875
|
+
observed: candidate.observed,
|
|
3876
|
+
characterDistance: candidate.characterDistance,
|
|
3877
|
+
pinyinDistance: candidate.pinyinDistance,
|
|
3878
|
+
snippet: candidate.snippet
|
|
3879
|
+
}),
|
|
3880
|
+
`</${tag}>`
|
|
3881
|
+
].join("\n");
|
|
3882
|
+
}).join("\n");
|
|
3883
|
+
const generated = await this.generateTaggedJson({
|
|
3884
|
+
workId,
|
|
3885
|
+
taskId,
|
|
3886
|
+
taskType: "relationship-analysis",
|
|
3887
|
+
signal: this.taskSignal(taskId),
|
|
3888
|
+
maxAttempts: 2,
|
|
3889
|
+
scope: { type: "selection", selection: snippets, suppressAutomaticContext: true },
|
|
3890
|
+
...(modelId ? { modelId } : {}),
|
|
3891
|
+
parameters: { temperature: 0.1 },
|
|
3892
|
+
instruction: [
|
|
3893
|
+
"你是人物名称变体确认器。判断每个片段中的 observed 是否指向对应 targetName,而不是另一个人物、普通词语或无法判断的对象。",
|
|
3894
|
+
"只依据每个候选附带的局部片段判断,禁止使用未提供的正文或设定。",
|
|
3895
|
+
"必须为每个 key 恰好输出一次结果,不得遗漏、重复或新增 key。",
|
|
3896
|
+
"verdict 只能是 same、separate、uncertain;confidence 是 0 到 1;reason 使用简短中文说明片段内依据。",
|
|
3897
|
+
"拼音相同或字形相近只能说明疑似,不能单独作为 same 的依据。上下文不能可靠确认时必须输出 uncertain。",
|
|
3898
|
+
"输出 JSON 数组,字段为 key、verdict、confidence、reason。"
|
|
3899
|
+
].join("\n")
|
|
3900
|
+
});
|
|
3901
|
+
callIds.push(generated.callId);
|
|
3902
|
+
const extracted = extractJson(generated.content);
|
|
3903
|
+
if (!Array.isArray(extracted))
|
|
3904
|
+
throw new Error("variant verification result is not an array");
|
|
3905
|
+
const byKey = new Map(candidateBatch.map((candidate) => [candidate.key, candidate]));
|
|
3906
|
+
const seen = new Set();
|
|
3907
|
+
for (const item of extracted) {
|
|
3908
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
3909
|
+
throw new Error("variant verification item is invalid");
|
|
3910
|
+
const value = item;
|
|
3911
|
+
const key = typeof value.key === "string" ? value.key : "";
|
|
3912
|
+
const verdict = value.verdict;
|
|
3913
|
+
const confidence = Number(value.confidence);
|
|
3914
|
+
const reason = typeof value.reason === "string" ? value.reason.trim() : "";
|
|
3915
|
+
const candidate = byKey.get(key);
|
|
3916
|
+
if (!candidate || seen.has(key) || !["same", "separate", "uncertain"].includes(String(verdict))
|
|
3917
|
+
|| !Number.isFinite(confidence) || confidence < 0 || confidence > 1 || !reason) {
|
|
3918
|
+
throw new Error("variant verification item is incomplete");
|
|
3919
|
+
}
|
|
3920
|
+
seen.add(key);
|
|
3921
|
+
decisions.push({
|
|
3922
|
+
...candidate,
|
|
3923
|
+
verdict: verdict,
|
|
3924
|
+
confidence,
|
|
3925
|
+
reason
|
|
3926
|
+
});
|
|
3927
|
+
}
|
|
3928
|
+
if (seen.size !== candidateBatch.length)
|
|
3929
|
+
throw new Error("variant verification result omitted candidates");
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
catch (error) {
|
|
3933
|
+
if (error instanceof AppError && error.code === "TASK_CANCELLED")
|
|
3934
|
+
throw error;
|
|
3935
|
+
throw new AppError(502, "RELATIONSHIP_VARIANT_VERIFICATION_FAILED", "疑似人物名身份确认失败,未写入人物关系", {
|
|
3936
|
+
candidateCount: candidates.length,
|
|
3937
|
+
completedCallCount: callIds.length
|
|
3938
|
+
});
|
|
3939
|
+
}
|
|
3940
|
+
return { decisions, callIds };
|
|
3941
|
+
}
|
|
3942
|
+
async selectRelationshipSources(workId, scope, characters, selectedCharacterIds, modelId, taskId) {
|
|
3943
|
+
let generation;
|
|
3944
|
+
try {
|
|
3945
|
+
generation = await this.ensureRelationshipSearchIndex(workId);
|
|
3946
|
+
}
|
|
3947
|
+
catch {
|
|
3948
|
+
throw new AppError(503, "RELATIONSHIP_INDEX_BUILD_FAILED", "人物关系来源索引构建失败,请稍后重试");
|
|
3949
|
+
}
|
|
3950
|
+
const local = await this.localRelationshipSourceSelection(workId, scope, characters, selectedCharacterIds, generation);
|
|
3951
|
+
const verified = await this.verifyRelationshipVariantCandidates(workId, local.candidates, modelId, taskId);
|
|
3952
|
+
const accepted = verified.decisions.filter((decision) => decision.verdict === "same" && decision.confidence >= 0.8);
|
|
3953
|
+
const selectedKeys = new Set([...local.exactKeys, ...accepted.map((decision) => this.relationshipIndexedSourceKey(decision.sourceType, decision.sourceId))]);
|
|
3954
|
+
const chapters = [];
|
|
3955
|
+
const settings = [];
|
|
3956
|
+
for (const key of selectedKeys) {
|
|
3957
|
+
const ref = this.relationshipIndexedSourceRef(key);
|
|
3958
|
+
if (ref.sourceType === "chapter") {
|
|
3959
|
+
const source = this.relationshipIndexedSource(workId, ref.sourceType, ref.sourceId);
|
|
3960
|
+
if (source)
|
|
3961
|
+
chapters.push({
|
|
3962
|
+
id: source.sourceId,
|
|
3963
|
+
workId,
|
|
3964
|
+
title: source.title,
|
|
3965
|
+
content: collapseAiBlankLines(source.content),
|
|
3966
|
+
versionNo: Number(source.version)
|
|
3967
|
+
});
|
|
3968
|
+
}
|
|
3969
|
+
else {
|
|
3970
|
+
const source = this.relationshipSettingSource(workId, ref.sourceType, ref.sourceId);
|
|
3971
|
+
if (source)
|
|
3972
|
+
settings.push(source);
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
const chapterOrder = new Map(this.store.db.all(`SELECT chapter.id FROM chapters chapter JOIN volumes volume ON volume.id = chapter.volume_id
|
|
3976
|
+
WHERE chapter.work_id = ? ORDER BY volume.sort_order, chapter.sort_order`, workId).map((row, index) => [String(row.id), index]));
|
|
3977
|
+
chapters.sort((left, right) => (chapterOrder.get(String(left.id)) ?? Number.MAX_SAFE_INTEGER) - (chapterOrder.get(String(right.id)) ?? Number.MAX_SAFE_INTEGER));
|
|
3978
|
+
settings.sort((left, right) => `${left.sourceType}:${left.id}`.localeCompare(`${right.sourceType}:${right.id}`, "zh-CN"));
|
|
3979
|
+
return {
|
|
3980
|
+
generation,
|
|
3981
|
+
chapters,
|
|
3982
|
+
settings,
|
|
3983
|
+
variantDecisions: verified.decisions,
|
|
3984
|
+
verificationCallIds: verified.callIds,
|
|
3985
|
+
summary: {
|
|
3986
|
+
policyVersion: RELATIONSHIP_SEARCH_POLICY_VERSION,
|
|
3987
|
+
indexGeneration: generation,
|
|
3988
|
+
exactSourceCount: new Set(local.exactKeys).size,
|
|
3989
|
+
fuzzyCandidateCount: local.candidates.length,
|
|
3990
|
+
confirmedSourceCount: new Set(accepted.map((decision) => this.relationshipIndexedSourceKey(decision.sourceType, decision.sourceId))).size,
|
|
3991
|
+
rejectedSourceCount: verified.decisions.filter((decision) => decision.verdict === "separate").length,
|
|
3992
|
+
uncertainSourceCount: verified.decisions.filter((decision) => decision.verdict === "uncertain" || (decision.verdict === "same" && decision.confidence < 0.8)).length,
|
|
3993
|
+
reviewIds: []
|
|
3994
|
+
}
|
|
3995
|
+
};
|
|
3996
|
+
}
|
|
3997
|
+
relationshipSettingSource(workId, sourceType, sourceId) {
|
|
3302
3998
|
const cleanStrings = (value) => {
|
|
3303
3999
|
if (typeof value === "string")
|
|
3304
4000
|
return collapseAiBlankLines(value);
|
|
@@ -3309,123 +4005,175 @@ export class AiManager {
|
|
|
3309
4005
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cleanStrings(item)]));
|
|
3310
4006
|
};
|
|
3311
4007
|
const serialize = (value) => JSON.stringify(cleanStrings(value), null, 2);
|
|
3312
|
-
const source = (
|
|
3313
|
-
id: sourceType === "setting" ?
|
|
4008
|
+
const source = (title, value, version) => ({
|
|
4009
|
+
id: sourceType === "setting" ? sourceId : `${sourceType}:${sourceId}`,
|
|
3314
4010
|
title,
|
|
3315
4011
|
sourceType,
|
|
3316
|
-
content: serialize(value)
|
|
4012
|
+
content: serialize(value),
|
|
4013
|
+
version: String(version ?? "")
|
|
4014
|
+
});
|
|
4015
|
+
try {
|
|
4016
|
+
if (sourceType === "work") {
|
|
4017
|
+
const item = this.store.getWork(sourceId);
|
|
4018
|
+
if (String(item.id) !== workId)
|
|
4019
|
+
return null;
|
|
4020
|
+
return source(`作品资料:${String(item.title)}`, {
|
|
4021
|
+
title: item.title, author: item.author, description: item.description, language: item.language
|
|
4022
|
+
}, item.versionNo);
|
|
4023
|
+
}
|
|
4024
|
+
if (sourceType === "setting") {
|
|
4025
|
+
const item = this.store.getSetting(sourceId);
|
|
4026
|
+
if (String(item.workId) !== workId)
|
|
4027
|
+
return null;
|
|
4028
|
+
return source(String(item.title), {
|
|
4029
|
+
category: item.category, content: item.content, tags: item.tags, status: item.status, authorNote: item.authorNote
|
|
4030
|
+
}, item.versionNo ?? item.updatedAt);
|
|
4031
|
+
}
|
|
4032
|
+
if (sourceType === "character") {
|
|
4033
|
+
const item = this.store.getCharacter(sourceId);
|
|
4034
|
+
if (String(item.workId) !== workId)
|
|
4035
|
+
return null;
|
|
4036
|
+
return source(`人物档案:${String(item.name)}`, {
|
|
4037
|
+
name: item.name, aliases: item.aliases, code: item.code, species: item.species, race: item.race,
|
|
4038
|
+
organizations: item.organizations, attributes: item.attributes, profile: item.profile,
|
|
4039
|
+
currentState: item.currentState, lockedFields: item.lockedFields
|
|
4040
|
+
}, item.versionNo);
|
|
4041
|
+
}
|
|
4042
|
+
if (sourceType === "race") {
|
|
4043
|
+
const item = this.store.getRace(sourceId);
|
|
4044
|
+
if (String(item.workId) !== workId)
|
|
4045
|
+
return null;
|
|
4046
|
+
return source(`种族设定:${String(item.name)}`, {
|
|
4047
|
+
name: item.name, description: item.description, lineage: item.lineage, settings: item.settings,
|
|
4048
|
+
effectiveSettings: item.effectiveSettings, members: item.members
|
|
4049
|
+
}, item.versionNo);
|
|
4050
|
+
}
|
|
4051
|
+
if (sourceType === "organization") {
|
|
4052
|
+
const item = this.store.getOrganization(sourceId);
|
|
4053
|
+
if (String(item.workId) !== workId)
|
|
4054
|
+
return null;
|
|
4055
|
+
return source(`组织设定:${String(item.name)}`, {
|
|
4056
|
+
name: item.name, description: item.description, settings: item.settings, members: item.members
|
|
4057
|
+
}, item.versionNo);
|
|
4058
|
+
}
|
|
4059
|
+
if (sourceType === "timeline-track") {
|
|
4060
|
+
const item = this.store.getTimelineTrack(sourceId);
|
|
4061
|
+
if (String(item.workId) !== workId)
|
|
4062
|
+
return null;
|
|
4063
|
+
return source(`时间轴:${String(item.name)}`, { name: item.name, description: item.description }, item.versionNo);
|
|
4064
|
+
}
|
|
4065
|
+
if (sourceType === "timeline-event") {
|
|
4066
|
+
const item = this.store.getTimelineEvent(sourceId);
|
|
4067
|
+
if (String(item.workId) !== workId)
|
|
4068
|
+
return null;
|
|
4069
|
+
return source(`时间线事件:${String(item.name)}`, {
|
|
4070
|
+
name: item.name,
|
|
4071
|
+
description: item.description,
|
|
4072
|
+
eventType: item.eventType,
|
|
4073
|
+
timeLabel: item.timeLabel,
|
|
4074
|
+
participants: (Array.isArray(item.participantIds) ? item.participantIds : []).map((characterId) => {
|
|
4075
|
+
try {
|
|
4076
|
+
return { characterId, name: this.store.getCharacter(String(characterId)).name };
|
|
4077
|
+
}
|
|
4078
|
+
catch {
|
|
4079
|
+
return { characterId, name: "已删除角色" };
|
|
4080
|
+
}
|
|
4081
|
+
}),
|
|
4082
|
+
location: item.location,
|
|
4083
|
+
causes: item.causes,
|
|
4084
|
+
impactScope: item.impactScope,
|
|
4085
|
+
evidence: item.evidence,
|
|
4086
|
+
status: item.status
|
|
4087
|
+
}, item.versionNo);
|
|
4088
|
+
}
|
|
4089
|
+
if (sourceType === "relationship") {
|
|
4090
|
+
const item = this.store.getRelationship(sourceId);
|
|
4091
|
+
if (String(item.workId) !== workId)
|
|
4092
|
+
return null;
|
|
4093
|
+
const fromName = String(this.store.getCharacter(String(item.fromCharacterId)).name);
|
|
4094
|
+
const toName = String(this.store.getCharacter(String(item.toCharacterId)).name);
|
|
4095
|
+
return source(`人物关系:${fromName} / ${toName}`, {
|
|
4096
|
+
fromCharacter: { id: item.fromCharacterId, name: fromName },
|
|
4097
|
+
toCharacter: { id: item.toCharacterId, name: toName },
|
|
4098
|
+
category: item.category,
|
|
4099
|
+
subtype: item.subtype,
|
|
4100
|
+
keywords: item.keywords,
|
|
4101
|
+
directed: item.directed,
|
|
4102
|
+
currentStatus: item.currentStatus,
|
|
4103
|
+
timeRange: item.timeRange,
|
|
4104
|
+
confidence: item.confidence,
|
|
4105
|
+
evidence: item.evidence,
|
|
4106
|
+
confirmationStatus: item.confirmationStatus,
|
|
4107
|
+
locked: item.locked
|
|
4108
|
+
}, item.versionNo);
|
|
4109
|
+
}
|
|
4110
|
+
if (sourceType === "chapter-outline") {
|
|
4111
|
+
const item = this.store.getChapterOutline(sourceId);
|
|
4112
|
+
if (!item || String(item.workId) !== workId)
|
|
4113
|
+
return null;
|
|
4114
|
+
const volumeTitle = String(this.store.getVolume(String(item.volumeId)).title);
|
|
4115
|
+
return source(`章节大纲:${volumeTitle} / ${String(item.chapterTitle)}`, {
|
|
4116
|
+
chapterTitle: item.chapterTitle,
|
|
4117
|
+
volumeTitle,
|
|
4118
|
+
goal: item.goal,
|
|
4119
|
+
conflict: item.conflict,
|
|
4120
|
+
turningPoint: item.turningPoint,
|
|
4121
|
+
notes: item.notes,
|
|
4122
|
+
status: item.status
|
|
4123
|
+
}, item.versionNo ?? item.updatedAt);
|
|
4124
|
+
}
|
|
4125
|
+
if (sourceType === "foreshadow") {
|
|
4126
|
+
const item = this.store.getForeshadow(sourceId);
|
|
4127
|
+
if (String(item.workId) !== workId)
|
|
4128
|
+
return null;
|
|
4129
|
+
return source(`伏笔:${String(item.title)}`, {
|
|
4130
|
+
title: item.title,
|
|
4131
|
+
description: item.description,
|
|
4132
|
+
status: item.status,
|
|
4133
|
+
importance: item.importance,
|
|
4134
|
+
resolutionNote: item.resolutionNote,
|
|
4135
|
+
occurrences: item.occurrences
|
|
4136
|
+
}, item.versionNo);
|
|
4137
|
+
}
|
|
4138
|
+
if (sourceType === "review") {
|
|
4139
|
+
const item = this.store.getReviewItem(sourceId);
|
|
4140
|
+
if (String(item.workId) !== workId)
|
|
4141
|
+
return null;
|
|
4142
|
+
return source(`审核项:${String(item.title)}`, {
|
|
4143
|
+
itemType: item.itemType,
|
|
4144
|
+
severity: item.severity,
|
|
4145
|
+
title: item.title,
|
|
4146
|
+
description: item.description,
|
|
4147
|
+
evidence: item.evidence,
|
|
4148
|
+
suggestion: item.suggestion,
|
|
4149
|
+
status: item.status,
|
|
4150
|
+
resolutionNote: item.resolutionNote
|
|
4151
|
+
}, item.updatedAt);
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
catch {
|
|
4155
|
+
return null;
|
|
4156
|
+
}
|
|
4157
|
+
return null;
|
|
4158
|
+
}
|
|
4159
|
+
relationshipSettingSources(workId, characters) {
|
|
4160
|
+
const refs = [
|
|
4161
|
+
["work", workId],
|
|
4162
|
+
...this.store.listSettings(workId).map((item) => ["setting", String(item.id)]),
|
|
4163
|
+
...characters.map((item) => ["character", String(item.id)]),
|
|
4164
|
+
...this.store.listRaces(workId).map((item) => ["race", String(item.id)]),
|
|
4165
|
+
...this.store.listOrganizations(workId).map((item) => ["organization", String(item.id)]),
|
|
4166
|
+
...this.store.listTimelineTracks(workId).map((item) => ["timeline-track", String(item.id)]),
|
|
4167
|
+
...this.store.listTimelineEvents(workId).map((item) => ["timeline-event", String(item.id)]),
|
|
4168
|
+
...this.store.listRelationships(workId).map((item) => ["relationship", String(item.id)]),
|
|
4169
|
+
...this.store.listChapterOutlines(workId).map((item) => ["chapter-outline", String(item.chapterId)]),
|
|
4170
|
+
...this.store.listForeshadows(workId).map((item) => ["foreshadow", String(item.id)]),
|
|
4171
|
+
...this.store.listReviewItems(workId).map((item) => ["review", String(item.id)])
|
|
4172
|
+
];
|
|
4173
|
+
return refs.flatMap(([sourceType, sourceId]) => {
|
|
4174
|
+
const materialized = this.relationshipSettingSource(workId, sourceType, sourceId);
|
|
4175
|
+
return materialized ? [materialized] : [];
|
|
3317
4176
|
});
|
|
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
4177
|
}
|
|
3430
4178
|
async runRelationshipAnalysis(workId, scope, modelId, taskId) {
|
|
3431
4179
|
const characters = this.store.listCharacters(workId);
|
|
@@ -3443,20 +4191,18 @@ export class AiManager {
|
|
|
3443
4191
|
.filter((character) => selectedCharacterIds.has(String(character.id)))
|
|
3444
4192
|
.map((character) => `${String(character.id)} | ${String(character.name)}`)
|
|
3445
4193
|
.join("\n");
|
|
3446
|
-
const
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
const availableSettings = settingsOnly || scope.includeAllSettings === true
|
|
4194
|
+
const sourceSelection = targeted
|
|
4195
|
+
? await this.selectRelationshipSources(workId, scope, characters, selectedCharacterIds, modelId, taskId)
|
|
4196
|
+
: null;
|
|
4197
|
+
const scopedChapters = targeted ? [] : settingsOnly ? [] : this.getScopeChapters(workId, scope);
|
|
4198
|
+
const chapters = sourceSelection?.chapters ?? scopedChapters;
|
|
4199
|
+
const availableSettings = targeted ? [] : settingsOnly || scope.includeAllSettings === true
|
|
3452
4200
|
? this.relationshipSettingSources(workId, characters)
|
|
3453
4201
|
: [];
|
|
3454
|
-
const settings =
|
|
3455
|
-
|
|
3456
|
-
: availableSettings;
|
|
3457
|
-
if (settingsOnly && availableSettings.length === 0)
|
|
4202
|
+
const settings = sourceSelection?.settings ?? availableSettings;
|
|
4203
|
+
if (!targeted && settingsOnly && availableSettings.length === 0)
|
|
3458
4204
|
throw new AppError(409, "SETTINGS_REQUIRED", "人物关系分析范围内没有设定数据");
|
|
3459
|
-
if (!settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
|
|
4205
|
+
if (!targeted && !settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
|
|
3460
4206
|
throw new AppError(409, "RELATIONSHIP_SOURCES_REQUIRED", "人物关系分析范围内没有章节或设定数据");
|
|
3461
4207
|
}
|
|
3462
4208
|
const chunks = [
|
|
@@ -3478,7 +4224,8 @@ export class AiManager {
|
|
|
3478
4224
|
targetedEvidenceCount: 0,
|
|
3479
4225
|
aggregationBatchCount: 0,
|
|
3480
4226
|
replacedRelationshipCount: 0,
|
|
3481
|
-
|
|
4227
|
+
sourceSelection: sourceSelection?.summary,
|
|
4228
|
+
callIds: sourceSelection?.verificationCallIds ?? []
|
|
3482
4229
|
};
|
|
3483
4230
|
}
|
|
3484
4231
|
const concurrency = this.configuredConcurrency(workId, "relationship-analysis", modelId);
|
|
@@ -3489,7 +4236,7 @@ export class AiManager {
|
|
|
3489
4236
|
const rawCandidates = [];
|
|
3490
4237
|
const chapterEvidenceCandidates = [];
|
|
3491
4238
|
const settingCandidates = [];
|
|
3492
|
-
const callIds = [];
|
|
4239
|
+
const callIds = [...(sourceSelection?.verificationCallIds ?? [])];
|
|
3493
4240
|
const settingsInstruction = [
|
|
3494
4241
|
"你是小说人物关系设定抽取器,不是续写者。只根据本批系统设定数据抽取角色规范表中人物之间被明确写出的长期关系。",
|
|
3495
4242
|
...(targeted ? ["被分析角色:", targetedRoster, "只输出至少一端属于被分析角色的关系。"] : []),
|
|
@@ -4079,6 +4826,52 @@ export class AiManager {
|
|
|
4079
4826
|
if (taskId && settingsOnly)
|
|
4080
4827
|
this.store.refreshTaskSourceVersions(taskId);
|
|
4081
4828
|
});
|
|
4829
|
+
if (sourceSelection) {
|
|
4830
|
+
const acceptedVariants = sourceSelection.variantDecisions.filter((decision) => decision.verdict === "same" && decision.confidence >= 0.8);
|
|
4831
|
+
const reviewIds = new Set();
|
|
4832
|
+
this.store.db.transaction(() => {
|
|
4833
|
+
for (const decision of acceptedVariants) {
|
|
4834
|
+
const observedIndex = decision.snippet.indexOf(decision.observed);
|
|
4835
|
+
const quote = observedIndex < 0
|
|
4836
|
+
? decision.snippet.slice(0, 160)
|
|
4837
|
+
: decision.snippet.slice(Math.max(0, observedIndex - 60), Math.min(decision.snippet.length, observedIndex + decision.observed.length + 60));
|
|
4838
|
+
const dedupeKey = this.store.hashContent([
|
|
4839
|
+
decision.targetCharacterId,
|
|
4840
|
+
normalizeRelationshipSearchText(decision.observed),
|
|
4841
|
+
decision.sourceType,
|
|
4842
|
+
decision.sourceId,
|
|
4843
|
+
decision.sourceVersion
|
|
4844
|
+
].join("|"));
|
|
4845
|
+
const review = this.store.createReviewItem(workId, {
|
|
4846
|
+
itemType: "character-name-variant",
|
|
4847
|
+
dedupeKey,
|
|
4848
|
+
severity: "medium",
|
|
4849
|
+
title: `疑似人物名错字:${decision.observed} → ${decision.targetName}`,
|
|
4850
|
+
description: `AI 判断来源“${decision.sourceTitle}”中的“${decision.observed}”可能指向人物“${decision.targetName}”。`,
|
|
4851
|
+
entityRefs: [{
|
|
4852
|
+
characterId: decision.targetCharacterId,
|
|
4853
|
+
sourceType: decision.sourceType,
|
|
4854
|
+
sourceId: decision.sourceId,
|
|
4855
|
+
sourceVersion: decision.sourceVersion
|
|
4856
|
+
}],
|
|
4857
|
+
evidence: [{
|
|
4858
|
+
sourceType: decision.sourceType,
|
|
4859
|
+
sourceId: decision.sourceId,
|
|
4860
|
+
sourceTitle: decision.sourceTitle,
|
|
4861
|
+
sourceVersion: decision.sourceVersion,
|
|
4862
|
+
observed: decision.observed,
|
|
4863
|
+
quote,
|
|
4864
|
+
confidence: decision.confidence,
|
|
4865
|
+
reason: decision.reason
|
|
4866
|
+
}],
|
|
4867
|
+
suggestion: `请核对“${decision.observed}”是否为“${decision.targetName}”的错别字;确认后再修改原文或登记别名。`,
|
|
4868
|
+
status: "pending"
|
|
4869
|
+
});
|
|
4870
|
+
reviewIds.add(String(review.id));
|
|
4871
|
+
}
|
|
4872
|
+
});
|
|
4873
|
+
sourceSelection.summary.reviewIds = [...reviewIds];
|
|
4874
|
+
}
|
|
4082
4875
|
const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
|
|
4083
4876
|
const relationshipResults = [...relationshipOutcomes.values()].map(({ action, relationship }) => {
|
|
4084
4877
|
const evidence = Array.isArray(relationship.evidence)
|
|
@@ -4159,6 +4952,7 @@ export class AiManager {
|
|
|
4159
4952
|
targetedEvidenceCount,
|
|
4160
4953
|
aggregationBatchCount,
|
|
4161
4954
|
replacedRelationshipCount,
|
|
4955
|
+
...(sourceSelection ? { sourceSelection: sourceSelection.summary } : {}),
|
|
4162
4956
|
callIds
|
|
4163
4957
|
};
|
|
4164
4958
|
}
|
|
@@ -4645,6 +5439,17 @@ export class AiManager {
|
|
|
4645
5439
|
this.assertAvailable(provider, model);
|
|
4646
5440
|
return { model, provider };
|
|
4647
5441
|
}
|
|
5442
|
+
analysisTaskModelPurpose(taskType) {
|
|
5443
|
+
if (taskType === "timeline-analysis")
|
|
5444
|
+
return "timeline-analysis";
|
|
5445
|
+
if (taskType === "relationship-analysis")
|
|
5446
|
+
return "relationship-analysis";
|
|
5447
|
+
if (taskType === "consistency-check")
|
|
5448
|
+
return "consistency-check";
|
|
5449
|
+
if (taskType === "chapter-analysis")
|
|
5450
|
+
return "chapter-analysis";
|
|
5451
|
+
return "book-analysis";
|
|
5452
|
+
}
|
|
4648
5453
|
configuredConcurrency(workId, taskType, modelId) {
|
|
4649
5454
|
const { provider } = this.resolveModel(workId, taskType, modelId);
|
|
4650
5455
|
return Math.round(clamp(numberValue(provider, "concurrency_limit") || 10, 1, 100));
|