@musnows/scriverse 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai.js +473 -78
- package/dist/ai.js.map +1 -1
- package/dist/app.js +89 -9
- package/dist/app.js.map +1 -1
- package/dist/database.js +19 -0
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +169 -34
- package/dist/public/index.html +2 -2
- package/dist/public/styles.css +92 -9
- package/dist/store.js +851 -6
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +1 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/ai.js
CHANGED
|
@@ -33,6 +33,45 @@ function thinkingParameters(provider, model) {
|
|
|
33
33
|
return { thinking: { type: boolValue(model, "thinking_enabled") ? "enabled" : "disabled" } };
|
|
34
34
|
}
|
|
35
35
|
const AGENT_TOOL_IDS = ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"];
|
|
36
|
+
function traceRecord(value) {
|
|
37
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
38
|
+
}
|
|
39
|
+
function taskTraceSourceRefs(initialMessages, rounds) {
|
|
40
|
+
const refs = [];
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const add = (type, title) => {
|
|
43
|
+
const normalizedTitle = title.trim();
|
|
44
|
+
if (!normalizedTitle)
|
|
45
|
+
return;
|
|
46
|
+
const key = `${type}|${normalizedTitle}`;
|
|
47
|
+
if (seen.has(key))
|
|
48
|
+
return;
|
|
49
|
+
seen.add(key);
|
|
50
|
+
refs.push({ type, title: normalizedTitle });
|
|
51
|
+
};
|
|
52
|
+
const roundMessages = rounds.flatMap((value) => {
|
|
53
|
+
const request = traceRecord(traceRecord(value).request);
|
|
54
|
+
return Array.isArray(request.messages) ? request.messages : [];
|
|
55
|
+
});
|
|
56
|
+
for (const message of [...initialMessages, ...roundMessages]) {
|
|
57
|
+
const content = String(traceRecord(message).content ?? "");
|
|
58
|
+
for (const match of content.matchAll(/<CHAPTER\b[^>]*\btitle="([^"]+)"[^>]*>/gu))
|
|
59
|
+
add("chapter", match[1] ?? "");
|
|
60
|
+
for (const match of content.matchAll(/<SETTING\b[^>]*\btitle="([^"]+)"[^>]*>/gu))
|
|
61
|
+
add("setting", match[1] ?? "");
|
|
62
|
+
for (const match of content.matchAll(/\[(?:# )?([^\]\n]+?)\s*\|\s*版本\s+\d+\]/gu)) {
|
|
63
|
+
const title = (match[1] ?? "").split(/\s+\/\s+/u).at(-1) ?? "";
|
|
64
|
+
add("chapter", title);
|
|
65
|
+
}
|
|
66
|
+
for (const match of content.matchAll(/(?:当前章节|所在章节):([^\n|]+?)\s*\|\s*版本\s+\d+/gu))
|
|
67
|
+
add("chapter", match[1] ?? "");
|
|
68
|
+
for (const match of content.matchAll(/"chapterTitle"\s*:\s*"([^"]+)"/gu))
|
|
69
|
+
add("chapter", match[1] ?? "");
|
|
70
|
+
for (const match of content.matchAll(/"chapterId"\s*:\s*"[^"]+"[\s\S]{0,240}?"title"\s*:\s*"([^"]+)"/gu))
|
|
71
|
+
add("chapter", match[1] ?? "");
|
|
72
|
+
}
|
|
73
|
+
return refs;
|
|
74
|
+
}
|
|
36
75
|
function redactProviderSecret(value, apiKey) {
|
|
37
76
|
if (!apiKey)
|
|
38
77
|
return value;
|
|
@@ -162,6 +201,11 @@ export function estimateAiTokens(value) {
|
|
|
162
201
|
}
|
|
163
202
|
return Math.max(1, Math.ceil(wideCharacters * 1.1 + narrowCharacters / 4));
|
|
164
203
|
}
|
|
204
|
+
export function collapseAiBlankLines(value) {
|
|
205
|
+
return value
|
|
206
|
+
.replace(/\r\n?/gu, "\n")
|
|
207
|
+
.replace(/\n[\t ]*\n(?:[\t ]*\n)+/gu, "\n\n");
|
|
208
|
+
}
|
|
165
209
|
function contextSearchTerms(value) {
|
|
166
210
|
const normalized = value.normalize("NFKC").toLocaleLowerCase("zh-CN");
|
|
167
211
|
const terms = new Set();
|
|
@@ -513,17 +557,20 @@ export class ContextBuilder {
|
|
|
513
557
|
}
|
|
514
558
|
buildPlan(workId, scope, maximumTokens = 60_000, bookSummaryMaximumTokens, query = "") {
|
|
515
559
|
const work = this.store.getWork(workId);
|
|
516
|
-
const includeAutomaticContext = scope.type !== "none";
|
|
560
|
+
const includeAutomaticContext = scope.type !== "none" && scope.suppressAutomaticContext !== true;
|
|
561
|
+
const settingsOnly = scope.type === "settings";
|
|
517
562
|
const constraints = includeAutomaticContext
|
|
518
563
|
? [`作品:${String(work.title)}\n作者:${String(work.author) || "未填写"}`]
|
|
519
564
|
: [];
|
|
520
565
|
const contentSections = [];
|
|
521
566
|
const availableSettings = this.store.listSettings(workId);
|
|
522
|
-
const contextualSettings =
|
|
567
|
+
const contextualSettings = !includeAutomaticContext || settingsOnly
|
|
568
|
+
? []
|
|
569
|
+
: scope.includeAllSettings ? availableSettings : availableSettings.filter((item) => item.locked);
|
|
523
570
|
const allCharacters = this.store.listCharacters(workId);
|
|
524
571
|
const lockedCharacters = allCharacters.filter((item) => Array.isArray(item.lockedFields) && item.lockedFields.length > 0);
|
|
525
572
|
const organizations = this.store.listOrganizations(workId);
|
|
526
|
-
const relationshipConstraints = scope.excludeRelationshipConstraints
|
|
573
|
+
const relationshipConstraints = !includeAutomaticContext || settingsOnly || scope.excludeRelationshipConstraints
|
|
527
574
|
? []
|
|
528
575
|
: selectRelationshipConstraints(this.store, workId, scope.characterIds ?? []);
|
|
529
576
|
if (includeAutomaticContext && contextualSettings.length > 0) {
|
|
@@ -531,7 +578,7 @@ export class ContextBuilder {
|
|
|
531
578
|
.map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`)
|
|
532
579
|
.join("\n")}`);
|
|
533
580
|
}
|
|
534
|
-
if (includeAutomaticContext && lockedCharacters.length > 0) {
|
|
581
|
+
if (includeAutomaticContext && !settingsOnly && lockedCharacters.length > 0) {
|
|
535
582
|
constraints.push(`作者锁定角色属性(硬约束):\n${lockedCharacters
|
|
536
583
|
.map((item) => {
|
|
537
584
|
const locked = item.lockedFields;
|
|
@@ -546,7 +593,7 @@ export class ContextBuilder {
|
|
|
546
593
|
})
|
|
547
594
|
.join("\n")}`);
|
|
548
595
|
}
|
|
549
|
-
if (includeAutomaticContext && organizations.length > 0) {
|
|
596
|
+
if (includeAutomaticContext && !settingsOnly && organizations.length > 0) {
|
|
550
597
|
constraints.push(`世界内组织:\n${organizations.map((item) => {
|
|
551
598
|
const settings = Array.isArray(item.settings) ? item.settings.map(String).filter(Boolean) : [];
|
|
552
599
|
const members = Array.isArray(item.members)
|
|
@@ -603,6 +650,9 @@ export class ContextBuilder {
|
|
|
603
650
|
}
|
|
604
651
|
}
|
|
605
652
|
}
|
|
653
|
+
else if (scope.type === "settings" && scope.selection) {
|
|
654
|
+
contentSections.push(`待分析设定:\n${scope.selection}`);
|
|
655
|
+
}
|
|
606
656
|
if (scope.includeBookSummary || scope.type === "book" || scope.type === "volume") {
|
|
607
657
|
this.appendBookSummary(contentSections, workId, bookSummaryMaximumTokens ?? Math.max(160, Math.floor(maximumTokens * 0.35)), query, scope.type === "volume" ? scope.volumeId : undefined);
|
|
608
658
|
}
|
|
@@ -631,7 +681,9 @@ export class ContextBuilder {
|
|
|
631
681
|
if (setting.workId !== workId)
|
|
632
682
|
throw new AppError(400, "SETTING_WORK_MISMATCH", "设定不属于当前作品");
|
|
633
683
|
}
|
|
634
|
-
constraints.push(
|
|
684
|
+
constraints.push(settingsOnly
|
|
685
|
+
? `设定集条目:\n${settings.map((item) => `<SETTING id="${String(item.id)}" title="${String(item.title).replaceAll('"', "'")}">\n${String(item.content)}\n</SETTING>`).join("\n\n")}`
|
|
686
|
+
: `选定设定:\n${settings.map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`).join("\n")}`);
|
|
635
687
|
}
|
|
636
688
|
if (scope.chapterIds?.length) {
|
|
637
689
|
const chapterIds = [...new Set(scope.chapterIds)]
|
|
@@ -658,7 +710,7 @@ export class ContextBuilder {
|
|
|
658
710
|
});
|
|
659
711
|
}
|
|
660
712
|
const sections = contentSections.map((text, order) => {
|
|
661
|
-
const required = /^(
|
|
713
|
+
const required = /^(?:当前选中文本|当前章节|所在章节|作者主动引用的章节|待分析设定)/u.test(text);
|
|
662
714
|
const summary = /章节概要(/u.test(text);
|
|
663
715
|
return {
|
|
664
716
|
id: `context-${order}`,
|
|
@@ -1312,9 +1364,14 @@ export class AiManager {
|
|
|
1312
1364
|
})), pagination);
|
|
1313
1365
|
}
|
|
1314
1366
|
getTaskTrace(taskId) {
|
|
1315
|
-
this.store.
|
|
1316
|
-
const rows = this.store.db.all(`SELECT call
|
|
1317
|
-
|
|
1367
|
+
this.store.getTaskWorkId(taskId);
|
|
1368
|
+
const rows = this.store.db.all(`SELECT call.id, call.task_type, call.provider_id, call.model_id, call.status, call.failure,
|
|
1369
|
+
call.input_chars, call.output_chars, call.created_at, call.completed_at, trace.call_id AS trace_call_id,
|
|
1370
|
+
trace.source_refs_json,
|
|
1371
|
+
CASE WHEN trace.call_id IS NULL THEN 0 ELSE json_array_length(trace.initial_messages_json) END AS initial_message_count,
|
|
1372
|
+
CASE WHEN trace.call_id IS NULL THEN 0 ELSE json_array_length(trace.rounds_json) END AS round_count,
|
|
1373
|
+
CASE WHEN trace.call_id IS NULL THEN 0 ELSE length(trace.initial_messages_json) + length(trace.rounds_json) END AS trace_chars,
|
|
1374
|
+
trace.created_at AS trace_created_at, trace.updated_at AS trace_updated_at, provider.name AS provider_name,
|
|
1318
1375
|
model.display_name AS model_display_name, model.model_id AS external_model_id
|
|
1319
1376
|
FROM ai_calls call
|
|
1320
1377
|
LEFT JOIN ai_call_traces trace ON trace.call_id = call.id
|
|
@@ -1323,7 +1380,11 @@ export class AiManager {
|
|
|
1323
1380
|
WHERE call.task_id = ?
|
|
1324
1381
|
ORDER BY call.created_at ASC, call.id ASC`, taskId);
|
|
1325
1382
|
const calls = rows.map((row) => {
|
|
1326
|
-
const hasTrace = row.
|
|
1383
|
+
const hasTrace = row.trace_call_id !== null && row.trace_call_id !== undefined;
|
|
1384
|
+
const failure = row.failure === null ? null : stringValue(row, "failure");
|
|
1385
|
+
const sourceRefs = hasTrace
|
|
1386
|
+
? json(stringValue(row, "source_refs_json"), [])
|
|
1387
|
+
: [];
|
|
1327
1388
|
return {
|
|
1328
1389
|
id: stringValue(row, "id"),
|
|
1329
1390
|
taskType: stringValue(row, "task_type"),
|
|
@@ -1338,17 +1399,19 @@ export class AiManager {
|
|
|
1338
1399
|
modelId: row.external_model_id === null ? null : stringValue(row, "external_model_id"),
|
|
1339
1400
|
deleted: row.model_display_name === null
|
|
1340
1401
|
},
|
|
1341
|
-
contextScope: json(stringValue(row, "context_scope_json"), {}),
|
|
1342
|
-
parameters: json(stringValue(row, "parameters_json"), {}),
|
|
1343
1402
|
status: stringValue(row, "status"),
|
|
1344
|
-
failure:
|
|
1403
|
+
failure: failure === null ? null : failure.slice(0, 1_000),
|
|
1404
|
+
failureTruncated: failure !== null && failure.length > 1_000,
|
|
1345
1405
|
inputChars: numberValue(row, "input_chars"),
|
|
1346
1406
|
outputChars: numberValue(row, "output_chars"),
|
|
1347
1407
|
createdAt: stringValue(row, "created_at"),
|
|
1348
1408
|
completedAt: row.completed_at === null ? null : stringValue(row, "completed_at"),
|
|
1409
|
+
sourceRefs,
|
|
1349
1410
|
trace: hasTrace ? {
|
|
1350
|
-
|
|
1351
|
-
|
|
1411
|
+
available: true,
|
|
1412
|
+
initialMessageCount: numberValue(row, "initial_message_count"),
|
|
1413
|
+
roundCount: numberValue(row, "round_count"),
|
|
1414
|
+
serializedChars: numberValue(row, "trace_chars"),
|
|
1352
1415
|
createdAt: stringValue(row, "trace_created_at"),
|
|
1353
1416
|
updatedAt: stringValue(row, "trace_updated_at")
|
|
1354
1417
|
} : null
|
|
@@ -1360,6 +1423,27 @@ export class AiManager {
|
|
|
1360
1423
|
calls
|
|
1361
1424
|
};
|
|
1362
1425
|
}
|
|
1426
|
+
getTaskTraceCall(taskId, callId) {
|
|
1427
|
+
this.store.getTaskWorkId(taskId);
|
|
1428
|
+
const row = this.store.db.get(`SELECT trace.initial_messages_json, trace.rounds_json, trace.created_at, trace.updated_at
|
|
1429
|
+
FROM ai_calls call JOIN ai_call_traces trace ON trace.call_id = call.id AND trace.task_id = call.task_id
|
|
1430
|
+
WHERE call.id = ? AND call.task_id = ?`, callId, taskId);
|
|
1431
|
+
if (!row)
|
|
1432
|
+
throw notFound("AI 调用追踪");
|
|
1433
|
+
const initialMessages = json(stringValue(row, "initial_messages_json"), []);
|
|
1434
|
+
const rounds = json(stringValue(row, "rounds_json"), []);
|
|
1435
|
+
return {
|
|
1436
|
+
taskId,
|
|
1437
|
+
callId,
|
|
1438
|
+
mode: "full",
|
|
1439
|
+
trace: {
|
|
1440
|
+
initialMessages,
|
|
1441
|
+
rounds,
|
|
1442
|
+
createdAt: stringValue(row, "created_at"),
|
|
1443
|
+
updatedAt: stringValue(row, "updated_at")
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1363
1447
|
async runTask(taskId, modelId) {
|
|
1364
1448
|
const task = this.store.getTask(taskId);
|
|
1365
1449
|
const workId = String(task.workId);
|
|
@@ -1650,7 +1734,7 @@ export class AiManager {
|
|
|
1650
1734
|
return this.contextBuilder.buildPlan(input.workId, input.scope, workContextBudgetTokens, bookSummaryMaximumTokens, input.instruction);
|
|
1651
1735
|
}
|
|
1652
1736
|
buildContext(input, model) {
|
|
1653
|
-
return this.buildContextPlan(input, model).context;
|
|
1737
|
+
return collapseAiBlankLines(this.buildContextPlan(input, model).context);
|
|
1654
1738
|
}
|
|
1655
1739
|
enabledAgentToolIds(workId, taskType, requestedToolIds) {
|
|
1656
1740
|
if (taskType !== "chat" && requestedToolIds === undefined)
|
|
@@ -1758,7 +1842,7 @@ export class AiManager {
|
|
|
1758
1842
|
const chapter = this.store.getChapter(chapterId);
|
|
1759
1843
|
if (chapter.workId !== workId)
|
|
1760
1844
|
return { chapterId, error: { code: "CHAPTER_WORK_MISMATCH", message: "The requested chapter belongs to a different work." } };
|
|
1761
|
-
const content = String(chapter.content);
|
|
1845
|
+
const content = collapseAiBlankLines(String(chapter.content));
|
|
1762
1846
|
const excerpt = content.slice(0, Math.max(0, remainingChars));
|
|
1763
1847
|
remainingChars -= excerpt.length;
|
|
1764
1848
|
return { chapterId, title: chapter.title, versionNo: chapter.versionNo, ...(include !== "content" ? { summary: summaries.get(chapterId) ?? "" } : {}), ...(include !== "summary" ? { content: excerpt, contentTruncated: excerpt.length < content.length } : {}) };
|
|
@@ -1820,7 +1904,7 @@ export class AiManager {
|
|
|
1820
1904
|
const section = this.store.getCharacterProfileSection(sectionId);
|
|
1821
1905
|
if (section.workId !== workId)
|
|
1822
1906
|
return { sectionId, error: { code: "CHARACTER_SECTION_WORK_MISMATCH", message: "The requested character section belongs to a different work." } };
|
|
1823
|
-
const content = String(section.contentMarkdown);
|
|
1907
|
+
const content = collapseAiBlankLines(String(section.contentMarkdown));
|
|
1824
1908
|
const excerpt = content.slice(0, Math.max(0, remainingChars));
|
|
1825
1909
|
remainingChars -= excerpt.length;
|
|
1826
1910
|
const character = this.store.getCharacter(String(section.characterId));
|
|
@@ -1881,14 +1965,14 @@ export class AiManager {
|
|
|
1881
1965
|
this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
|
|
1882
1966
|
status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskId ?? null, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(parameters), context.length + input.instruction.length, timestamp, currentRequestActor()?.userId ?? null);
|
|
1883
1967
|
if (input.taskId) {
|
|
1884
|
-
this.store.db.run(`INSERT INTO ai_call_traces (call_id, task_id, initial_messages_json, rounds_json, created_at, updated_at)
|
|
1885
|
-
VALUES (?, ?, ?, '[]', ?, ?)`, callId, input.taskId, JSON.stringify(messages), timestamp, timestamp);
|
|
1968
|
+
this.store.db.run(`INSERT INTO ai_call_traces (call_id, task_id, initial_messages_json, rounds_json, source_refs_json, created_at, updated_at)
|
|
1969
|
+
VALUES (?, ?, ?, '[]', ?, ?, ?)`, callId, input.taskId, JSON.stringify(messages), JSON.stringify(taskTraceSourceRefs(messages, [])), timestamp, timestamp);
|
|
1886
1970
|
}
|
|
1887
1971
|
});
|
|
1888
1972
|
const saveTrace = () => {
|
|
1889
1973
|
if (!input.taskId)
|
|
1890
1974
|
return;
|
|
1891
|
-
this.store.db.run("UPDATE ai_call_traces SET rounds_json = ?, updated_at = ? WHERE call_id = ?", JSON.stringify(traceRounds), now(), callId);
|
|
1975
|
+
this.store.db.run("UPDATE ai_call_traces SET rounds_json = ?, source_refs_json = ?, updated_at = ? WHERE call_id = ?", JSON.stringify(traceRounds), JSON.stringify(taskTraceSourceRefs(messages, traceRounds)), now(), callId);
|
|
1892
1976
|
};
|
|
1893
1977
|
const callStartedAt = process.hrtime.bigint();
|
|
1894
1978
|
logger.info("ai.call.started", {
|
|
@@ -3213,10 +3297,141 @@ export class AiManager {
|
|
|
3213
3297
|
}
|
|
3214
3298
|
};
|
|
3215
3299
|
}
|
|
3300
|
+
relationshipSettingSources(workId, characters) {
|
|
3301
|
+
const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
|
|
3302
|
+
const cleanStrings = (value) => {
|
|
3303
|
+
if (typeof value === "string")
|
|
3304
|
+
return collapseAiBlankLines(value);
|
|
3305
|
+
if (Array.isArray(value))
|
|
3306
|
+
return value.map(cleanStrings);
|
|
3307
|
+
if (!value || typeof value !== "object")
|
|
3308
|
+
return value;
|
|
3309
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cleanStrings(item)]));
|
|
3310
|
+
};
|
|
3311
|
+
const serialize = (value) => JSON.stringify(cleanStrings(value), null, 2);
|
|
3312
|
+
const source = (sourceType, sourceId, title, value) => ({
|
|
3313
|
+
id: sourceType === "setting" ? String(sourceId) : `${sourceType}:${String(sourceId)}`,
|
|
3314
|
+
title,
|
|
3315
|
+
sourceType,
|
|
3316
|
+
content: serialize(value)
|
|
3317
|
+
});
|
|
3318
|
+
const work = this.store.getWork(workId);
|
|
3319
|
+
const settings = this.store.listSettings(workId).map((item) => source("setting", item.id, String(item.title), {
|
|
3320
|
+
category: item.category,
|
|
3321
|
+
content: item.content,
|
|
3322
|
+
tags: item.tags,
|
|
3323
|
+
status: item.status,
|
|
3324
|
+
authorNote: item.authorNote
|
|
3325
|
+
}));
|
|
3326
|
+
const characterSources = this.store.listCharacters(workId, true).map((item) => source("character", item.id, `人物档案:${String(item.name)}`, {
|
|
3327
|
+
name: item.name,
|
|
3328
|
+
aliases: item.aliases,
|
|
3329
|
+
code: item.code,
|
|
3330
|
+
species: item.species,
|
|
3331
|
+
race: item.race,
|
|
3332
|
+
organizations: item.organizations,
|
|
3333
|
+
attributes: item.attributes,
|
|
3334
|
+
profile: item.profile,
|
|
3335
|
+
currentState: item.currentState,
|
|
3336
|
+
lockedFields: item.lockedFields
|
|
3337
|
+
}));
|
|
3338
|
+
const races = this.store.listRaces(workId).map((item) => source("race", item.id, `种族设定:${String(item.name)}`, {
|
|
3339
|
+
name: item.name,
|
|
3340
|
+
description: item.description,
|
|
3341
|
+
lineage: item.lineage,
|
|
3342
|
+
settings: item.settings,
|
|
3343
|
+
effectiveSettings: item.effectiveSettings,
|
|
3344
|
+
members: item.members
|
|
3345
|
+
}));
|
|
3346
|
+
const organizations = this.store.listOrganizations(workId).map((item) => source("organization", item.id, `组织设定:${String(item.name)}`, {
|
|
3347
|
+
name: item.name,
|
|
3348
|
+
description: item.description,
|
|
3349
|
+
settings: item.settings,
|
|
3350
|
+
members: item.members
|
|
3351
|
+
}));
|
|
3352
|
+
const tracks = this.store.listTimelineTracks(workId).map((item) => source("timeline-track", item.id, `时间轴:${String(item.name)}`, {
|
|
3353
|
+
name: item.name,
|
|
3354
|
+
description: item.description
|
|
3355
|
+
}));
|
|
3356
|
+
const timeline = this.store.listTimelineEvents(workId).map((item) => source("timeline-event", item.id, `时间线事件:${String(item.name)}`, {
|
|
3357
|
+
name: item.name,
|
|
3358
|
+
description: item.description,
|
|
3359
|
+
eventType: item.eventType,
|
|
3360
|
+
timeLabel: item.timeLabel,
|
|
3361
|
+
participants: (Array.isArray(item.participantIds) ? item.participantIds : []).map((characterId) => ({
|
|
3362
|
+
characterId,
|
|
3363
|
+
name: characterNameById.get(String(characterId)) ?? "已删除角色"
|
|
3364
|
+
})),
|
|
3365
|
+
location: item.location,
|
|
3366
|
+
causes: item.causes,
|
|
3367
|
+
impactScope: item.impactScope,
|
|
3368
|
+
evidence: item.evidence,
|
|
3369
|
+
status: item.status
|
|
3370
|
+
}));
|
|
3371
|
+
const relationships = this.store.listRelationships(workId).map((item) => source("relationship", item.id, `人物关系:${characterNameById.get(String(item.fromCharacterId)) ?? "已删除角色"} / ${characterNameById.get(String(item.toCharacterId)) ?? "已删除角色"}`, {
|
|
3372
|
+
fromCharacter: { id: item.fromCharacterId, name: characterNameById.get(String(item.fromCharacterId)) ?? "已删除角色" },
|
|
3373
|
+
toCharacter: { id: item.toCharacterId, name: characterNameById.get(String(item.toCharacterId)) ?? "已删除角色" },
|
|
3374
|
+
category: item.category,
|
|
3375
|
+
subtype: item.subtype,
|
|
3376
|
+
keywords: item.keywords,
|
|
3377
|
+
directed: item.directed,
|
|
3378
|
+
currentStatus: item.currentStatus,
|
|
3379
|
+
timeRange: item.timeRange,
|
|
3380
|
+
confidence: item.confidence,
|
|
3381
|
+
evidence: item.evidence,
|
|
3382
|
+
confirmationStatus: item.confirmationStatus,
|
|
3383
|
+
locked: item.locked
|
|
3384
|
+
}));
|
|
3385
|
+
const outlines = this.store.listChapterOutlines(workId).map((item) => source("chapter-outline", item.chapterId, `章节大纲:${String(item.volumeTitle)} / ${String(item.chapterTitle)}`, {
|
|
3386
|
+
chapterTitle: item.chapterTitle,
|
|
3387
|
+
volumeTitle: item.volumeTitle,
|
|
3388
|
+
goal: item.goal,
|
|
3389
|
+
conflict: item.conflict,
|
|
3390
|
+
turningPoint: item.turningPoint,
|
|
3391
|
+
notes: item.notes,
|
|
3392
|
+
status: item.status
|
|
3393
|
+
}));
|
|
3394
|
+
const foreshadows = this.store.listForeshadows(workId).map((item) => source("foreshadow", item.id, `伏笔:${String(item.title)}`, {
|
|
3395
|
+
title: item.title,
|
|
3396
|
+
description: item.description,
|
|
3397
|
+
status: item.status,
|
|
3398
|
+
importance: item.importance,
|
|
3399
|
+
resolutionNote: item.resolutionNote,
|
|
3400
|
+
occurrences: item.occurrences
|
|
3401
|
+
}));
|
|
3402
|
+
const reviews = this.store.listReviewItems(workId).map((item) => source("review", item.id, `审核项:${String(item.title)}`, {
|
|
3403
|
+
itemType: item.itemType,
|
|
3404
|
+
severity: item.severity,
|
|
3405
|
+
title: item.title,
|
|
3406
|
+
description: item.description,
|
|
3407
|
+
evidence: item.evidence,
|
|
3408
|
+
suggestion: item.suggestion,
|
|
3409
|
+
status: item.status,
|
|
3410
|
+
resolutionNote: item.resolutionNote
|
|
3411
|
+
}));
|
|
3412
|
+
return [source("work", work.id, `作品资料:${String(work.title)}`, {
|
|
3413
|
+
title: work.title,
|
|
3414
|
+
author: work.author,
|
|
3415
|
+
description: work.description,
|
|
3416
|
+
language: work.language
|
|
3417
|
+
}), ...settings, ...characterSources, ...races, ...organizations, ...tracks, ...timeline, ...relationships, ...outlines, ...foreshadows, ...reviews];
|
|
3418
|
+
}
|
|
3419
|
+
relationshipSearchKeywords(characters, selectedCharacterIds) {
|
|
3420
|
+
return [...new Set(characters
|
|
3421
|
+
.filter((character) => selectedCharacterIds.has(String(character.id)))
|
|
3422
|
+
.flatMap((character) => [String(character.name), ...(character.aliases ?? []).map(String)])
|
|
3423
|
+
.map((keyword) => keyword.normalize("NFKC").trim().toLocaleLowerCase("zh-CN"))
|
|
3424
|
+
.filter(Boolean))];
|
|
3425
|
+
}
|
|
3426
|
+
relationshipSourceContainsKeyword(source, keywords) {
|
|
3427
|
+
const searchable = `${String(source.title ?? "")}\n${String(source.content ?? "")}`.normalize("NFKC").toLocaleLowerCase("zh-CN");
|
|
3428
|
+
return keywords.some((keyword) => searchable.includes(keyword));
|
|
3429
|
+
}
|
|
3216
3430
|
async runRelationshipAnalysis(workId, scope, modelId, taskId) {
|
|
3217
3431
|
const characters = this.store.listCharacters(workId);
|
|
3218
3432
|
if (characters.length < 2)
|
|
3219
3433
|
throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
|
|
3434
|
+
const settingsOnly = scope.type === "settings";
|
|
3220
3435
|
const selectedCharacterIds = new Set(scope.characterIds ?? []);
|
|
3221
3436
|
for (const characterId of selectedCharacterIds) {
|
|
3222
3437
|
const character = characters.find((item) => item.id === characterId);
|
|
@@ -3228,33 +3443,87 @@ export class AiManager {
|
|
|
3228
3443
|
.filter((character) => selectedCharacterIds.has(String(character.id)))
|
|
3229
3444
|
.map((character) => `${String(character.id)} | ${String(character.name)}`)
|
|
3230
3445
|
.join("\n");
|
|
3231
|
-
const
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3446
|
+
const searchKeywords = targeted ? this.relationshipSearchKeywords(characters, selectedCharacterIds) : [];
|
|
3447
|
+
const scopedChapters = settingsOnly ? [] : this.getScopeChapters(workId, scope);
|
|
3448
|
+
const chapters = targeted
|
|
3449
|
+
? scopedChapters.filter((chapter) => this.relationshipSourceContainsKeyword(chapter, searchKeywords))
|
|
3450
|
+
: scopedChapters;
|
|
3451
|
+
const availableSettings = settingsOnly || scope.includeAllSettings === true
|
|
3452
|
+
? this.relationshipSettingSources(workId, characters)
|
|
3453
|
+
: [];
|
|
3454
|
+
const settings = targeted
|
|
3455
|
+
? availableSettings.filter((setting) => this.relationshipSourceContainsKeyword(setting, searchKeywords))
|
|
3456
|
+
: availableSettings;
|
|
3457
|
+
if (settingsOnly && availableSettings.length === 0)
|
|
3458
|
+
throw new AppError(409, "SETTINGS_REQUIRED", "人物关系分析范围内没有设定数据");
|
|
3459
|
+
if (!settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
|
|
3460
|
+
throw new AppError(409, "RELATIONSHIP_SOURCES_REQUIRED", "人物关系分析范围内没有章节或设定数据");
|
|
3461
|
+
}
|
|
3462
|
+
const chunks = [
|
|
3463
|
+
...this.buildChapterChunks(chapters, 12_000).map((chunk) => ({ ...chunk, sourceKind: "chapter" })),
|
|
3464
|
+
...this.buildSettingChunks(settings, 12_000).map((chunk) => ({ ...chunk, sourceKind: "setting" }))
|
|
3465
|
+
];
|
|
3466
|
+
if (targeted && chunks.length === 0) {
|
|
3467
|
+
return {
|
|
3468
|
+
relationshipIds: [],
|
|
3469
|
+
candidateCount: 0,
|
|
3470
|
+
rawCandidateCount: 0,
|
|
3471
|
+
skipped: [{ index: -1, reason: "没有章节或设定数据命中被分析角色的名称或别名" }],
|
|
3472
|
+
batchCount: 0,
|
|
3473
|
+
coveredChapterCount: 0,
|
|
3474
|
+
coveredSettingCount: 0,
|
|
3475
|
+
fallbackSegmentCount: 0,
|
|
3476
|
+
policyOmittedSegmentCount: 0,
|
|
3477
|
+
targetedCharacterIds: [...selectedCharacterIds],
|
|
3478
|
+
targetedEvidenceCount: 0,
|
|
3479
|
+
aggregationBatchCount: 0,
|
|
3480
|
+
replacedRelationshipCount: 0,
|
|
3481
|
+
callIds: []
|
|
3482
|
+
};
|
|
3483
|
+
}
|
|
3235
3484
|
const concurrency = this.configuredConcurrency(workId, "relationship-analysis", modelId);
|
|
3236
3485
|
const roster = characters.map((character) => {
|
|
3237
3486
|
const aliases = character.aliases.filter((alias) => this.isSafeGlobalAlias(alias));
|
|
3238
3487
|
return `${String(character.id)} | ${String(character.name)}${aliases.length ? ` | 别名:${aliases.join("、")}` : ""}`;
|
|
3239
3488
|
}).join("\n");
|
|
3240
3489
|
const rawCandidates = [];
|
|
3490
|
+
const chapterEvidenceCandidates = [];
|
|
3491
|
+
const settingCandidates = [];
|
|
3241
3492
|
const callIds = [];
|
|
3242
|
-
const
|
|
3493
|
+
const settingsInstruction = [
|
|
3494
|
+
"你是小说人物关系设定抽取器,不是续写者。只根据本批系统设定数据抽取角色规范表中人物之间被明确写出的长期关系。",
|
|
3495
|
+
...(targeted ? ["被分析角色:", targetedRoster, "只输出至少一端属于被分析角色的关系。"] : []),
|
|
3496
|
+
"完整角色规范表:",
|
|
3497
|
+
roster,
|
|
3498
|
+
"硬规则:",
|
|
3499
|
+
"1. 本批 SETTING 条目是本次唯一事实来源;它可能来自作品设定、人物档案、种族、组织、时间线、已有关系、大纲、伏笔或审核项,不得引用未提供的数据或常识补全关系。",
|
|
3500
|
+
"2. 人名、别名、昵称和拼写变体必须归一到唯一 characterId,禁止创造角色或把相似名字强行合并。",
|
|
3501
|
+
"3. 只抽取条目明确陈述的长期亲属、社会、情感或冲突关系;同场出现、同属阵营、相似背景和推测性措辞不能生成关系。",
|
|
3502
|
+
"4. 父母→子女、君王→臣属、导师→学生、施害者→受害者、倾慕者→被倾慕者使用 directed=true;伴侣、朋友、手足、盟友、互为宿敌使用 directed=false。",
|
|
3503
|
+
"5. category 只能是 family、social、emotional、conflict、uncertain;confidence 低于 0.6 不输出。",
|
|
3504
|
+
"6. subtype 使用简短稳定中文词;同一人物对、同一 category、同一 subtype 只输出一次,不得输出反向重复边。",
|
|
3505
|
+
"7. keywords 提供 2 至 8 个描述双方互动、权力结构、情感阶段或剧情张力的中文关键词。",
|
|
3506
|
+
"8. 每条 evidence 必须提供 settingId、settingTitle、quote、supports;quote 必须是对应设定条目中的连续原文短句且不超过 80 字。",
|
|
3507
|
+
"9. evidence 的 quote 和 supports 必须能共同识别关系双方及关系类型,不能只凭一方名字或模糊代词建立关系。",
|
|
3508
|
+
"10. 输出 JSON 数组。字段:fromCharacterId、toCharacterId、category、subtype、keywords、directed、currentStatus、timeRange、confidence、evidence。没有明确关系时输出 []。"
|
|
3509
|
+
].join("\n");
|
|
3510
|
+
const extractChunk = async (chunk, maxAttempts = 3) => {
|
|
3243
3511
|
const generated = await this.generateTaggedJson({
|
|
3244
3512
|
workId,
|
|
3245
3513
|
taskId,
|
|
3246
3514
|
taskType: "relationship-analysis",
|
|
3247
3515
|
signal: this.taskSignal(taskId),
|
|
3248
3516
|
maxAttempts,
|
|
3249
|
-
scope:
|
|
3250
|
-
type: "
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3517
|
+
scope: chunk.sourceKind === "setting"
|
|
3518
|
+
? { type: "settings", selection: chunk.text }
|
|
3519
|
+
: {
|
|
3520
|
+
type: "selection",
|
|
3521
|
+
selection: chunk.text,
|
|
3522
|
+
...(targeted ? { suppressAutomaticContext: true } : {})
|
|
3523
|
+
},
|
|
3255
3524
|
...(modelId ? { modelId } : {}),
|
|
3256
3525
|
parameters: { temperature: 0.1 },
|
|
3257
|
-
instruction: targeted ? [
|
|
3526
|
+
instruction: chunk.sourceKind === "setting" ? settingsInstruction : targeted ? [
|
|
3258
3527
|
"你是定向人物关系证据收集器。本阶段只建立跨章节证据账本,不下最终关系结论。",
|
|
3259
3528
|
"被分析角色:",
|
|
3260
3529
|
targetedRoster,
|
|
@@ -3299,9 +3568,11 @@ export class AiManager {
|
|
|
3299
3568
|
"24. 共同执行一次任务、同属一个组织、在同一集体场景中被感谢或落泪、替第三人转发消息,都不能单独证明同事、朋友或盟友。此类关系必须有原文明示身份,或至少两个不同章节的持续互动证据。"
|
|
3300
3569
|
].join("\n"),
|
|
3301
3570
|
extraSystemPrompt: [
|
|
3302
|
-
|
|
3303
|
-
? "
|
|
3304
|
-
:
|
|
3571
|
+
chunk.sourceKind === "setting"
|
|
3572
|
+
? "本次只允许使用提供的系统设定条目。每条结论都必须能回溯到对应 settingId 的原文引文。"
|
|
3573
|
+
: targeted
|
|
3574
|
+
? "你正在为指定角色收集可审计的跨章节关系线索。不得在证据收集阶段把单次互动直接判定为长期关系。"
|
|
3575
|
+
: "关系候选必须可审计。严禁把梦境伴侣、醉后梦话、单次约定、同章共现、礼称、同族归属、救援照护或类比提及写成现实长期关系。逐句校验说话人和关系方向。",
|
|
3305
3576
|
scope.additionalPrompt?.trim() ? `作者追加的关系分析提示:\n${scope.additionalPrompt.trim()}` : ""
|
|
3306
3577
|
].filter(Boolean).join("\n\n")
|
|
3307
3578
|
});
|
|
@@ -3315,26 +3586,32 @@ export class AiManager {
|
|
|
3315
3586
|
};
|
|
3316
3587
|
const chunkResults = await this.processChunks(chunks, concurrency, async (chunk) => {
|
|
3317
3588
|
if (taskId && this.store.getTask(taskId).status !== "running") {
|
|
3318
|
-
return { candidates: [], callIds: [], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
3589
|
+
return { sourceKind: chunk.sourceKind, candidates: [], callIds: [], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
3319
3590
|
}
|
|
3320
3591
|
try {
|
|
3321
|
-
const extracted = await extractChunk(chunk
|
|
3322
|
-
return { candidates: extracted.candidates, callIds: [extracted.callId], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
3592
|
+
const extracted = await extractChunk(chunk, 1);
|
|
3593
|
+
return { sourceKind: chunk.sourceKind, candidates: extracted.candidates, callIds: [extracted.callId], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
3323
3594
|
}
|
|
3324
3595
|
catch {
|
|
3596
|
+
if (chunk.sourceKind === "setting")
|
|
3597
|
+
throw new AppError(502, "AI_SETTINGS_BATCH_FAILED", "设定数据人物关系分析批次失败");
|
|
3325
3598
|
const segments = this.splitMarkedChapters(chunk.text);
|
|
3326
|
-
|
|
3599
|
+
const fallback = await this.runChapterSegmentFallback(segments, taskId, async (text, maxAttempts) => extractChunk({ sourceKind: "chapter", text }, maxAttempts), undefined, concurrency);
|
|
3600
|
+
return { sourceKind: chunk.sourceKind, ...fallback };
|
|
3327
3601
|
}
|
|
3328
3602
|
}, (completed) => {
|
|
3329
3603
|
if (taskId && this.store.getTask(taskId).status === "running") {
|
|
3330
|
-
const maximumProgress = targeted ? 72 : 92;
|
|
3604
|
+
const maximumProgress = targeted && !settingsOnly ? 72 : 92;
|
|
3331
3605
|
this.store.updateTask(taskId, { status: "running", progress: Math.min(maximumProgress, 5 + Math.round(completed / chunks.length * (maximumProgress - 5))) });
|
|
3332
3606
|
}
|
|
3333
3607
|
});
|
|
3334
3608
|
let fallbackSegmentCount = 0;
|
|
3335
3609
|
let policyOmittedSegmentCount = 0;
|
|
3336
3610
|
for (const result of chunkResults) {
|
|
3337
|
-
|
|
3611
|
+
if (result.sourceKind === "setting")
|
|
3612
|
+
settingCandidates.push(...result.candidates);
|
|
3613
|
+
else
|
|
3614
|
+
chapterEvidenceCandidates.push(...result.candidates);
|
|
3338
3615
|
callIds.push(...result.callIds);
|
|
3339
3616
|
fallbackSegmentCount += result.fallbackSegmentCount;
|
|
3340
3617
|
policyOmittedSegmentCount += result.policyOmittedSegmentCount;
|
|
@@ -3349,11 +3626,13 @@ export class AiManager {
|
|
|
3349
3626
|
batchCount: chunks.length
|
|
3350
3627
|
});
|
|
3351
3628
|
}
|
|
3352
|
-
|
|
3629
|
+
if (!targeted)
|
|
3630
|
+
rawCandidates.push(...chapterEvidenceCandidates, ...settingCandidates);
|
|
3631
|
+
const targetedEvidenceCount = targeted ? chapterEvidenceCandidates.length + settingCandidates.length : 0;
|
|
3353
3632
|
let aggregationBatchCount = 0;
|
|
3354
|
-
if (targeted &&
|
|
3633
|
+
if (targeted && !settingsOnly && chapterEvidenceCandidates.length > 0) {
|
|
3355
3634
|
const evidenceGroups = new Map();
|
|
3356
|
-
for (const evidence of
|
|
3635
|
+
for (const evidence of chapterEvidenceCandidates) {
|
|
3357
3636
|
const target = String(evidence.targetCharacterId ?? "");
|
|
3358
3637
|
const related = String(evidence.relatedCharacterId ?? evidence.relatedReference ?? "unknown");
|
|
3359
3638
|
const key = `${target}|${related}`;
|
|
@@ -3386,9 +3665,7 @@ export class AiManager {
|
|
|
3386
3665
|
maxAttempts: 2,
|
|
3387
3666
|
scope: {
|
|
3388
3667
|
type: "entities",
|
|
3389
|
-
|
|
3390
|
-
characterIds: [...selectedCharacterIds],
|
|
3391
|
-
excludeRelationshipConstraints: scope.replaceExistingRelationships === true
|
|
3668
|
+
suppressAutomaticContext: true
|
|
3392
3669
|
},
|
|
3393
3670
|
...(modelId ? { modelId } : {}),
|
|
3394
3671
|
parameters: { temperature: 0.1 },
|
|
@@ -3428,10 +3705,14 @@ export class AiManager {
|
|
|
3428
3705
|
this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 72 + Math.round(completed / evidenceBatches.length * 20)) });
|
|
3429
3706
|
}
|
|
3430
3707
|
});
|
|
3431
|
-
rawCandidates.
|
|
3708
|
+
rawCandidates.push(...aggregationResults.flatMap((result) => result.candidates));
|
|
3432
3709
|
callIds.push(...aggregationResults.map((result) => result.callId));
|
|
3433
3710
|
}
|
|
3711
|
+
if (targeted)
|
|
3712
|
+
rawCandidates.push(...settingCandidates);
|
|
3434
3713
|
const chapterById = new Map(chapters.map((chapter) => [String(chapter.id), chapter]));
|
|
3714
|
+
const settingById = new Map(settings.map((setting) => [String(setting.id), setting]));
|
|
3715
|
+
const relationshipEvidenceKey = (item) => `${String(item.settingId ?? item.chapterId)}|${String(item.quote)}`;
|
|
3435
3716
|
const categories = new Set(["family", "social", "emotional", "conflict", "uncertain"]);
|
|
3436
3717
|
const merged = new Map();
|
|
3437
3718
|
const skipped = [];
|
|
@@ -3480,21 +3761,36 @@ export class AiManager {
|
|
|
3480
3761
|
[fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
|
|
3481
3762
|
const evidence = (Array.isArray(candidate.evidence) ? candidate.evidence : [])
|
|
3482
3763
|
.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
|
3483
|
-
.
|
|
3484
|
-
if (typeof item.
|
|
3485
|
-
return
|
|
3764
|
+
.flatMap((item) => {
|
|
3765
|
+
if (typeof item.quote !== "string" || item.quote.trim().length > 80)
|
|
3766
|
+
return [];
|
|
3767
|
+
if (typeof item.settingId === "string") {
|
|
3768
|
+
const setting = settingById.get(item.settingId);
|
|
3769
|
+
if (!setting || !this.quoteExists(String(setting.content), item.quote))
|
|
3770
|
+
return [];
|
|
3771
|
+
return [{
|
|
3772
|
+
settingId: item.settingId,
|
|
3773
|
+
settingTitle: String(setting.title),
|
|
3774
|
+
quote: item.quote.trim(),
|
|
3775
|
+
contextType: "setting",
|
|
3776
|
+
supports: typeof item.supports === "string" ? item.supports : ""
|
|
3777
|
+
}];
|
|
3778
|
+
}
|
|
3779
|
+
if (typeof item.chapterId !== "string")
|
|
3780
|
+
return [];
|
|
3486
3781
|
const chapter = chapterById.get(item.chapterId);
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3782
|
+
if (!chapter || !this.quoteExists(String(chapter.content), item.quote))
|
|
3783
|
+
return [];
|
|
3784
|
+
return [{
|
|
3785
|
+
chapterId: item.chapterId,
|
|
3786
|
+
chapterTitle: String(chapter.title),
|
|
3787
|
+
quote: item.quote.trim(),
|
|
3788
|
+
contextType: typeof item.contextType === "string" ? item.contextType : "current",
|
|
3789
|
+
supports: typeof item.supports === "string" ? item.supports : ""
|
|
3790
|
+
}];
|
|
3791
|
+
});
|
|
3496
3792
|
if (evidence.length === 0) {
|
|
3497
|
-
skipped.push({ index, reason: "
|
|
3793
|
+
skipped.push({ index, reason: "证据引文未在对应章节或设定条目命中" });
|
|
3498
3794
|
return;
|
|
3499
3795
|
}
|
|
3500
3796
|
const evidenceText = evidence.map((item) => String(item.quote)).join("\n");
|
|
@@ -3514,9 +3810,9 @@ export class AiManager {
|
|
|
3514
3810
|
}
|
|
3515
3811
|
}
|
|
3516
3812
|
if (category === "conflict" && subtype === "宿敌") {
|
|
3517
|
-
const
|
|
3813
|
+
const evidenceSources = new Set(evidence.map((item) => String(item.settingId ?? item.chapterId)));
|
|
3518
3814
|
const explicitlyLongRunning = /宿敌|世仇|死敌|多年|长期|世代|一直.{0,24}(?:敌|威胁|对抗|杀手)|远古.{0,16}(?:战|敌)|多次.{0,16}(?:交战|对抗|冲突)/u.test(evidenceText);
|
|
3519
|
-
if (
|
|
3815
|
+
if (evidenceSources.size < 2 && !explicitlyLongRunning)
|
|
3520
3816
|
subtype = "战时敌对";
|
|
3521
3817
|
}
|
|
3522
3818
|
const key = [fromCharacterId, toCharacterId, category, this.normalizeReference(subtype), directed ? "1" : "0"].join("|");
|
|
@@ -3525,9 +3821,9 @@ export class AiManager {
|
|
|
3525
3821
|
current.confidence = Math.max(current.confidence, confidence);
|
|
3526
3822
|
current.currentStatus = currentStatus || current.currentStatus;
|
|
3527
3823
|
current.keywords = [...new Set([...current.keywords, ...keywords])].slice(0, 8);
|
|
3528
|
-
const seenEvidence = new Set(current.evidence.map(
|
|
3824
|
+
const seenEvidence = new Set(current.evidence.map(relationshipEvidenceKey));
|
|
3529
3825
|
for (const item of evidence) {
|
|
3530
|
-
const evidenceKey =
|
|
3826
|
+
const evidenceKey = relationshipEvidenceKey(item);
|
|
3531
3827
|
if (!seenEvidence.has(evidenceKey))
|
|
3532
3828
|
current.evidence.push(item);
|
|
3533
3829
|
}
|
|
@@ -3552,20 +3848,30 @@ export class AiManager {
|
|
|
3552
3848
|
const durablePeerSubtype = /同事|同僚|共事|搭档|伙伴|朋友|好友|挚友|老友|旧友|战友|盟友|同盟|联盟/u.test(candidate.subtype);
|
|
3553
3849
|
if (candidate.category !== "social" || !durablePeerSubtype)
|
|
3554
3850
|
continue;
|
|
3555
|
-
const
|
|
3851
|
+
const evidenceSources = new Set(candidate.evidence.map((item) => String(item.settingId ?? item.chapterId)));
|
|
3556
3852
|
const evidenceText = candidate.evidence.map((item) => String(item.quote)).join("\n");
|
|
3557
3853
|
const explicitlyLongRunning = /同事|同僚|共事|搭档|伙伴|朋友|好友|挚友|老友|旧友|老朋友|战友|盟友|同盟|联盟|结盟|缔盟|盟约|旧识|好久不见|多年|长期|几十年|经常|往日|一直.{0,16}(?:合作|支援|互助|并肩)/u.test(evidenceText);
|
|
3558
|
-
if (
|
|
3854
|
+
if (evidenceSources.size >= 2 || explicitlyLongRunning)
|
|
3559
3855
|
continue;
|
|
3560
|
-
skipped.push({ index: -1, reason:
|
|
3856
|
+
skipped.push({ index: -1, reason: candidate.evidence.every((item) => item.contextType === "setting")
|
|
3857
|
+
? `“${candidate.subtype}”缺少设定集中的明确长期关系表述`
|
|
3858
|
+
: `“${candidate.subtype}”缺少明确身份或跨章长期互动证据` });
|
|
3561
3859
|
merged.delete(key);
|
|
3562
3860
|
}
|
|
3563
3861
|
const relationshipIds = [];
|
|
3862
|
+
const relationshipOutcomes = new Map();
|
|
3863
|
+
const recordRelationshipOutcome = (action, relationship) => {
|
|
3864
|
+
const relationshipId = String(relationship.id);
|
|
3865
|
+
const previous = relationshipOutcomes.get(relationshipId);
|
|
3866
|
+
relationshipOutcomes.set(relationshipId, {
|
|
3867
|
+
action: previous?.action === "created" ? "created" : action,
|
|
3868
|
+
relationship
|
|
3869
|
+
});
|
|
3870
|
+
};
|
|
3564
3871
|
let replacedRelationshipCount = 0;
|
|
3872
|
+
if (!this.taskCanCommit(taskId))
|
|
3873
|
+
return { interrupted: true, callIds };
|
|
3565
3874
|
this.store.db.transaction(() => {
|
|
3566
|
-
if (!targeted && scope.type === "book") {
|
|
3567
|
-
this.store.db.run("DELETE FROM relationships WHERE work_id = ? AND confirmation_status = 'pending' AND locked = 0", workId);
|
|
3568
|
-
}
|
|
3569
3875
|
if (targeted && scope.replaceExistingRelationships === true) {
|
|
3570
3876
|
const relationshipsToReplace = this.store.listRelationships(workId).filter((relationship) => selectedCharacterIds.has(String(relationship.fromCharacterId)) || selectedCharacterIds.has(String(relationship.toCharacterId)));
|
|
3571
3877
|
for (const relationship of relationshipsToReplace)
|
|
@@ -3573,6 +3879,7 @@ export class AiManager {
|
|
|
3573
3879
|
replacedRelationshipCount = relationshipsToReplace.length;
|
|
3574
3880
|
}
|
|
3575
3881
|
const existing = this.store.listRelationships(workId).filter((relationship) => relationship.confirmationStatus !== "rejected");
|
|
3882
|
+
const appendOnly = scope.replaceExistingRelationships !== true;
|
|
3576
3883
|
const unorderedPairKey = (fromCharacterId, toCharacterId) => {
|
|
3577
3884
|
const pair = [String(fromCharacterId), String(toCharacterId)].sort((left, right) => left.localeCompare(right));
|
|
3578
3885
|
return `${pair[0]}|${pair[1]}`;
|
|
@@ -3690,11 +3997,15 @@ export class AiManager {
|
|
|
3690
3997
|
});
|
|
3691
3998
|
if (duplicateIndex >= 0) {
|
|
3692
3999
|
const duplicate = existing[duplicateIndex];
|
|
4000
|
+
if (appendOnly) {
|
|
4001
|
+
skipped.push({ index: -1, reason: `已有相同的“${candidate.subtype}”关系,追加模式不更新` });
|
|
4002
|
+
continue;
|
|
4003
|
+
}
|
|
3693
4004
|
if (duplicate.confirmationStatus === "pending" && duplicate.locked !== true) {
|
|
3694
4005
|
const mergedEvidence = [...(duplicate.evidence ?? [])];
|
|
3695
|
-
const seenEvidence = new Set(mergedEvidence.map(
|
|
4006
|
+
const seenEvidence = new Set(mergedEvidence.map(relationshipEvidenceKey));
|
|
3696
4007
|
for (const item of candidate.evidence) {
|
|
3697
|
-
const evidenceKey =
|
|
4008
|
+
const evidenceKey = relationshipEvidenceKey(item);
|
|
3698
4009
|
if (!seenEvidence.has(evidenceKey))
|
|
3699
4010
|
mergedEvidence.push(item);
|
|
3700
4011
|
}
|
|
@@ -3706,6 +4017,10 @@ export class AiManager {
|
|
|
3706
4017
|
timeRange: candidate.timeRange,
|
|
3707
4018
|
evidence: mergedEvidence
|
|
3708
4019
|
}, "analysis", taskId ?? null, "AI 合并关系证据");
|
|
4020
|
+
recordRelationshipOutcome("updated", existing[duplicateIndex]);
|
|
4021
|
+
}
|
|
4022
|
+
else {
|
|
4023
|
+
recordRelationshipOutcome("unchanged", duplicate);
|
|
3709
4024
|
}
|
|
3710
4025
|
if (candidatePeerStrength > 0) {
|
|
3711
4026
|
for (let index = existing.length - 1; index >= 0; index -= 1) {
|
|
@@ -3736,12 +4051,12 @@ export class AiManager {
|
|
|
3736
4051
|
&& peerSocialStrength(relationship) > 0
|
|
3737
4052
|
&& peerSocialStrength(relationship) < candidatePeerStrength)
|
|
3738
4053
|
: -1;
|
|
3739
|
-
if (weakerExistingPeerIndex >= 0) {
|
|
4054
|
+
if (weakerExistingPeerIndex >= 0 && !appendOnly) {
|
|
3740
4055
|
const weaker = existing[weakerExistingPeerIndex];
|
|
3741
4056
|
const mergedEvidence = [...(weaker.evidence ?? [])];
|
|
3742
|
-
const seenEvidence = new Set(mergedEvidence.map(
|
|
4057
|
+
const seenEvidence = new Set(mergedEvidence.map(relationshipEvidenceKey));
|
|
3743
4058
|
for (const item of candidate.evidence) {
|
|
3744
|
-
const evidenceKey =
|
|
4059
|
+
const evidenceKey = relationshipEvidenceKey(item);
|
|
3745
4060
|
if (!seenEvidence.has(evidenceKey))
|
|
3746
4061
|
mergedEvidence.push(item);
|
|
3747
4062
|
}
|
|
@@ -3753,18 +4068,60 @@ export class AiManager {
|
|
|
3753
4068
|
timeRange: candidate.timeRange,
|
|
3754
4069
|
evidence: mergedEvidence
|
|
3755
4070
|
}, "analysis", taskId ?? null, "AI 更新关系强度");
|
|
4071
|
+
recordRelationshipOutcome("updated", existing[weakerExistingPeerIndex]);
|
|
3756
4072
|
continue;
|
|
3757
4073
|
}
|
|
3758
4074
|
const relationship = this.store.createRelationship(workId, { ...candidate, confirmationStatus: "pending", locked: false }, "analysis", taskId ?? null);
|
|
3759
4075
|
relationshipIds.push(String(relationship.id));
|
|
4076
|
+
recordRelationshipOutcome("created", relationship);
|
|
3760
4077
|
existing.push(relationship);
|
|
3761
4078
|
}
|
|
4079
|
+
if (taskId && settingsOnly)
|
|
4080
|
+
this.store.refreshTaskSourceVersions(taskId);
|
|
4081
|
+
});
|
|
4082
|
+
const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
|
|
4083
|
+
const relationshipResults = [...relationshipOutcomes.values()].map(({ action, relationship }) => {
|
|
4084
|
+
const evidence = Array.isArray(relationship.evidence)
|
|
4085
|
+
? relationship.evidence.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
|
4086
|
+
: [];
|
|
4087
|
+
return {
|
|
4088
|
+
relationshipId: String(relationship.id),
|
|
4089
|
+
action,
|
|
4090
|
+
fromCharacterId: String(relationship.fromCharacterId),
|
|
4091
|
+
fromCharacterName: characterNameById.get(String(relationship.fromCharacterId)) ?? String(relationship.fromCharacterId),
|
|
4092
|
+
toCharacterId: String(relationship.toCharacterId),
|
|
4093
|
+
toCharacterName: characterNameById.get(String(relationship.toCharacterId)) ?? String(relationship.toCharacterId),
|
|
4094
|
+
category: String(relationship.category),
|
|
4095
|
+
subtype: String(relationship.subtype),
|
|
4096
|
+
keywords: Array.isArray(relationship.keywords) ? relationship.keywords.map(String) : [],
|
|
4097
|
+
directed: Boolean(relationship.directed),
|
|
4098
|
+
currentStatus: String(relationship.currentStatus ?? ""),
|
|
4099
|
+
timeRange: relationship.timeRange && typeof relationship.timeRange === "object" && !Array.isArray(relationship.timeRange)
|
|
4100
|
+
? relationship.timeRange
|
|
4101
|
+
: {},
|
|
4102
|
+
confidence: Number(relationship.confidence ?? 0),
|
|
4103
|
+
confirmationStatus: String(relationship.confirmationStatus ?? "pending"),
|
|
4104
|
+
evidenceCount: evidence.length,
|
|
4105
|
+
evidence: evidence.slice(0, 3).map((item) => ({
|
|
4106
|
+
chapterId: String(item.chapterId ?? ""),
|
|
4107
|
+
chapterTitle: String(item.chapterTitle ?? chapterById.get(String(item.chapterId))?.title ?? ""),
|
|
4108
|
+
quote: String(item.quote ?? ""),
|
|
4109
|
+
supports: String(item.supports ?? "")
|
|
4110
|
+
})),
|
|
4111
|
+
evidenceTruncated: evidence.length > 3
|
|
4112
|
+
};
|
|
3762
4113
|
});
|
|
4114
|
+
const createdCount = relationshipResults.filter((item) => item.action === "created").length;
|
|
4115
|
+
const updatedCount = relationshipResults.filter((item) => item.action === "updated").length;
|
|
4116
|
+
const unchangedCount = relationshipResults.filter((item) => item.action === "unchanged").length;
|
|
3763
4117
|
this.store.audit(workId, "relationship.analysis.completed", "work", workId, {
|
|
3764
4118
|
batchCount: chunks.length,
|
|
3765
4119
|
coveredChapterCount: chapters.length,
|
|
4120
|
+
coveredSettingCount: settings.length,
|
|
3766
4121
|
rawCandidateCount: rawCandidates.length,
|
|
3767
4122
|
savedCount: relationshipIds.length,
|
|
4123
|
+
updatedCount,
|
|
4124
|
+
unchangedCount,
|
|
3768
4125
|
skippedCount: skipped.length,
|
|
3769
4126
|
fallbackSegmentCount,
|
|
3770
4127
|
policyOmittedSegmentCount,
|
|
@@ -3777,10 +4134,25 @@ export class AiManager {
|
|
|
3777
4134
|
return {
|
|
3778
4135
|
relationshipIds,
|
|
3779
4136
|
candidateCount: relationshipIds.length,
|
|
4137
|
+
createdCount,
|
|
4138
|
+
updatedCount,
|
|
4139
|
+
unchangedCount,
|
|
4140
|
+
relationshipResults,
|
|
4141
|
+
analysisTarget: {
|
|
4142
|
+
mode: targeted ? "targeted-characters" : "all-relationships",
|
|
4143
|
+
scopeType: scope.type,
|
|
4144
|
+
characterIds: [...selectedCharacterIds],
|
|
4145
|
+
characterNames: characters
|
|
4146
|
+
.filter((character) => selectedCharacterIds.has(String(character.id)))
|
|
4147
|
+
.map((character) => String(character.name)),
|
|
4148
|
+
coveredChapterCount: chapters.length,
|
|
4149
|
+
includeAllSettings: scope.includeAllSettings === true
|
|
4150
|
+
},
|
|
3780
4151
|
rawCandidateCount: rawCandidates.length,
|
|
3781
4152
|
skipped,
|
|
3782
4153
|
batchCount: chunks.length,
|
|
3783
4154
|
coveredChapterCount: chapters.length,
|
|
4155
|
+
coveredSettingCount: settings.length,
|
|
3784
4156
|
fallbackSegmentCount,
|
|
3785
4157
|
policyOmittedSegmentCount,
|
|
3786
4158
|
targetedCharacterIds: [...selectedCharacterIds],
|
|
@@ -3876,6 +4248,29 @@ export class AiManager {
|
|
|
3876
4248
|
flush();
|
|
3877
4249
|
return chunks;
|
|
3878
4250
|
}
|
|
4251
|
+
buildSettingChunks(settings, maximumChars = 10_000) {
|
|
4252
|
+
const chunks = [];
|
|
4253
|
+
let text = "";
|
|
4254
|
+
let settingIds = [];
|
|
4255
|
+
const flush = () => {
|
|
4256
|
+
if (settingIds.length === 0)
|
|
4257
|
+
return;
|
|
4258
|
+
chunks.push({ text, settingIds });
|
|
4259
|
+
text = "";
|
|
4260
|
+
settingIds = [];
|
|
4261
|
+
};
|
|
4262
|
+
for (const setting of settings) {
|
|
4263
|
+
const block = `<SETTING id="${String(setting.id)}" title="${String(setting.title).replaceAll('"', "'")}">\n${String(setting.content)}\n</SETTING>\n`;
|
|
4264
|
+
if (settingIds.length > 0 && text.length + block.length > maximumChars)
|
|
4265
|
+
flush();
|
|
4266
|
+
text += block;
|
|
4267
|
+
settingIds.push(String(setting.id));
|
|
4268
|
+
if (text.length >= maximumChars)
|
|
4269
|
+
flush();
|
|
4270
|
+
}
|
|
4271
|
+
flush();
|
|
4272
|
+
return chunks;
|
|
4273
|
+
}
|
|
3879
4274
|
splitMarkedChapters(text) {
|
|
3880
4275
|
const segments = text.match(/<CHAPTER\b[^>]*>[\s\S]*?<\/CHAPTER>/gu) ?? [];
|
|
3881
4276
|
return segments.length > 0 ? segments : [text];
|