@musnows/scriverse 0.5.1 → 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 +438 -147
- package/dist/ai.js.map +1 -1
- package/dist/app.js +89 -13
- 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 +111 -45
- package/dist/public/index.html +2 -2
- package/dist/public/styles.css +57 -6
- package/dist/store.js +851 -6
- package/dist/store.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/ai.js
CHANGED
|
@@ -33,65 +33,44 @@ 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
|
-
const TASK_TRACE_PREVIEW_CHARACTER_LIMIT = 3_000;
|
|
37
36
|
function traceRecord(value) {
|
|
38
37
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
39
38
|
}
|
|
40
|
-
function
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const available = contents[index].length - allocations[index];
|
|
53
|
-
const granted = Math.min(available, share, remaining);
|
|
54
|
-
allocations[index] = allocations[index] + granted;
|
|
55
|
-
remaining -= granted;
|
|
56
|
-
if (allocations[index] < contents[index].length)
|
|
57
|
-
next.push(index);
|
|
58
|
-
}
|
|
59
|
-
active = next;
|
|
60
|
-
}
|
|
61
|
-
return {
|
|
62
|
-
messages: records.map((message, index) => {
|
|
63
|
-
const content = contents[index];
|
|
64
|
-
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
65
|
-
return {
|
|
66
|
-
role: typeof message.role === "string" ? message.role : "user",
|
|
67
|
-
content: content.slice(0, allocations[index]),
|
|
68
|
-
contentChars: content.length,
|
|
69
|
-
contentTruncated: allocations[index] < content.length,
|
|
70
|
-
toolCallCount: toolCalls.length,
|
|
71
|
-
...(typeof message.tool_call_id === "string" ? { tool_call_id: message.tool_call_id } : {})
|
|
72
|
-
};
|
|
73
|
-
}),
|
|
74
|
-
totalChars: contents.reduce((total, content) => total + content.length, 0),
|
|
75
|
-
truncated: contents.some((content, index) => allocations[index] < content.length)
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
function summarizeTaskTraceRound(value) {
|
|
79
|
-
const round = traceRecord(value);
|
|
80
|
-
const request = traceRecord(round.request);
|
|
81
|
-
const messages = Array.isArray(request.messages) ? request.messages : [];
|
|
82
|
-
const attempts = Array.isArray(round.attempts) ? round.attempts : [];
|
|
83
|
-
const toolExecutions = Array.isArray(round.toolExecutions) ? round.toolExecutions : [];
|
|
84
|
-
return {
|
|
85
|
-
round: typeof round.round === "number" ? round.round : 1,
|
|
86
|
-
requestedAt: typeof round.requestedAt === "string" ? round.requestedAt : "",
|
|
87
|
-
messageCount: messages.length,
|
|
88
|
-
promptChars: messages.reduce((total, message) => {
|
|
89
|
-
const content = traceRecord(message).content;
|
|
90
|
-
return total + (content === null ? 0 : String(content ?? "").length);
|
|
91
|
-
}, 0),
|
|
92
|
-
attemptCount: attempts.length,
|
|
93
|
-
toolExecutionCount: toolExecutions.length
|
|
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 });
|
|
94
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;
|
|
95
74
|
}
|
|
96
75
|
function redactProviderSecret(value, apiKey) {
|
|
97
76
|
if (!apiKey)
|
|
@@ -222,6 +201,11 @@ export function estimateAiTokens(value) {
|
|
|
222
201
|
}
|
|
223
202
|
return Math.max(1, Math.ceil(wideCharacters * 1.1 + narrowCharacters / 4));
|
|
224
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
|
+
}
|
|
225
209
|
function contextSearchTerms(value) {
|
|
226
210
|
const normalized = value.normalize("NFKC").toLocaleLowerCase("zh-CN");
|
|
227
211
|
const terms = new Set();
|
|
@@ -573,17 +557,20 @@ export class ContextBuilder {
|
|
|
573
557
|
}
|
|
574
558
|
buildPlan(workId, scope, maximumTokens = 60_000, bookSummaryMaximumTokens, query = "") {
|
|
575
559
|
const work = this.store.getWork(workId);
|
|
576
|
-
const includeAutomaticContext = scope.type !== "none";
|
|
560
|
+
const includeAutomaticContext = scope.type !== "none" && scope.suppressAutomaticContext !== true;
|
|
561
|
+
const settingsOnly = scope.type === "settings";
|
|
577
562
|
const constraints = includeAutomaticContext
|
|
578
563
|
? [`作品:${String(work.title)}\n作者:${String(work.author) || "未填写"}`]
|
|
579
564
|
: [];
|
|
580
565
|
const contentSections = [];
|
|
581
566
|
const availableSettings = this.store.listSettings(workId);
|
|
582
|
-
const contextualSettings =
|
|
567
|
+
const contextualSettings = !includeAutomaticContext || settingsOnly
|
|
568
|
+
? []
|
|
569
|
+
: scope.includeAllSettings ? availableSettings : availableSettings.filter((item) => item.locked);
|
|
583
570
|
const allCharacters = this.store.listCharacters(workId);
|
|
584
571
|
const lockedCharacters = allCharacters.filter((item) => Array.isArray(item.lockedFields) && item.lockedFields.length > 0);
|
|
585
572
|
const organizations = this.store.listOrganizations(workId);
|
|
586
|
-
const relationshipConstraints = scope.excludeRelationshipConstraints
|
|
573
|
+
const relationshipConstraints = !includeAutomaticContext || settingsOnly || scope.excludeRelationshipConstraints
|
|
587
574
|
? []
|
|
588
575
|
: selectRelationshipConstraints(this.store, workId, scope.characterIds ?? []);
|
|
589
576
|
if (includeAutomaticContext && contextualSettings.length > 0) {
|
|
@@ -591,7 +578,7 @@ export class ContextBuilder {
|
|
|
591
578
|
.map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`)
|
|
592
579
|
.join("\n")}`);
|
|
593
580
|
}
|
|
594
|
-
if (includeAutomaticContext && lockedCharacters.length > 0) {
|
|
581
|
+
if (includeAutomaticContext && !settingsOnly && lockedCharacters.length > 0) {
|
|
595
582
|
constraints.push(`作者锁定角色属性(硬约束):\n${lockedCharacters
|
|
596
583
|
.map((item) => {
|
|
597
584
|
const locked = item.lockedFields;
|
|
@@ -606,7 +593,7 @@ export class ContextBuilder {
|
|
|
606
593
|
})
|
|
607
594
|
.join("\n")}`);
|
|
608
595
|
}
|
|
609
|
-
if (includeAutomaticContext && organizations.length > 0) {
|
|
596
|
+
if (includeAutomaticContext && !settingsOnly && organizations.length > 0) {
|
|
610
597
|
constraints.push(`世界内组织:\n${organizations.map((item) => {
|
|
611
598
|
const settings = Array.isArray(item.settings) ? item.settings.map(String).filter(Boolean) : [];
|
|
612
599
|
const members = Array.isArray(item.members)
|
|
@@ -663,6 +650,9 @@ export class ContextBuilder {
|
|
|
663
650
|
}
|
|
664
651
|
}
|
|
665
652
|
}
|
|
653
|
+
else if (scope.type === "settings" && scope.selection) {
|
|
654
|
+
contentSections.push(`待分析设定:\n${scope.selection}`);
|
|
655
|
+
}
|
|
666
656
|
if (scope.includeBookSummary || scope.type === "book" || scope.type === "volume") {
|
|
667
657
|
this.appendBookSummary(contentSections, workId, bookSummaryMaximumTokens ?? Math.max(160, Math.floor(maximumTokens * 0.35)), query, scope.type === "volume" ? scope.volumeId : undefined);
|
|
668
658
|
}
|
|
@@ -691,7 +681,9 @@ export class ContextBuilder {
|
|
|
691
681
|
if (setting.workId !== workId)
|
|
692
682
|
throw new AppError(400, "SETTING_WORK_MISMATCH", "设定不属于当前作品");
|
|
693
683
|
}
|
|
694
|
-
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")}`);
|
|
695
687
|
}
|
|
696
688
|
if (scope.chapterIds?.length) {
|
|
697
689
|
const chapterIds = [...new Set(scope.chapterIds)]
|
|
@@ -718,7 +710,7 @@ export class ContextBuilder {
|
|
|
718
710
|
});
|
|
719
711
|
}
|
|
720
712
|
const sections = contentSections.map((text, order) => {
|
|
721
|
-
const required = /^(
|
|
713
|
+
const required = /^(?:当前选中文本|当前章节|所在章节|作者主动引用的章节|待分析设定)/u.test(text);
|
|
722
714
|
const summary = /章节概要(/u.test(text);
|
|
723
715
|
return {
|
|
724
716
|
id: `context-${order}`,
|
|
@@ -1372,9 +1364,10 @@ export class AiManager {
|
|
|
1372
1364
|
})), pagination);
|
|
1373
1365
|
}
|
|
1374
1366
|
getTaskTrace(taskId) {
|
|
1375
|
-
this.store.
|
|
1367
|
+
this.store.getTaskWorkId(taskId);
|
|
1376
1368
|
const rows = this.store.db.all(`SELECT call.id, call.task_type, call.provider_id, call.model_id, call.status, call.failure,
|
|
1377
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,
|
|
1378
1371
|
CASE WHEN trace.call_id IS NULL THEN 0 ELSE json_array_length(trace.initial_messages_json) END AS initial_message_count,
|
|
1379
1372
|
CASE WHEN trace.call_id IS NULL THEN 0 ELSE json_array_length(trace.rounds_json) END AS round_count,
|
|
1380
1373
|
CASE WHEN trace.call_id IS NULL THEN 0 ELSE length(trace.initial_messages_json) + length(trace.rounds_json) END AS trace_chars,
|
|
@@ -1389,6 +1382,9 @@ export class AiManager {
|
|
|
1389
1382
|
const calls = rows.map((row) => {
|
|
1390
1383
|
const hasTrace = row.trace_call_id !== null && row.trace_call_id !== undefined;
|
|
1391
1384
|
const failure = row.failure === null ? null : stringValue(row, "failure");
|
|
1385
|
+
const sourceRefs = hasTrace
|
|
1386
|
+
? json(stringValue(row, "source_refs_json"), [])
|
|
1387
|
+
: [];
|
|
1392
1388
|
return {
|
|
1393
1389
|
id: stringValue(row, "id"),
|
|
1394
1390
|
taskType: stringValue(row, "task_type"),
|
|
@@ -1410,6 +1406,7 @@ export class AiManager {
|
|
|
1410
1406
|
outputChars: numberValue(row, "output_chars"),
|
|
1411
1407
|
createdAt: stringValue(row, "created_at"),
|
|
1412
1408
|
completedAt: row.completed_at === null ? null : stringValue(row, "completed_at"),
|
|
1409
|
+
sourceRefs,
|
|
1413
1410
|
trace: hasTrace ? {
|
|
1414
1411
|
available: true,
|
|
1415
1412
|
initialMessageCount: numberValue(row, "initial_message_count"),
|
|
@@ -1426,8 +1423,8 @@ export class AiManager {
|
|
|
1426
1423
|
calls
|
|
1427
1424
|
};
|
|
1428
1425
|
}
|
|
1429
|
-
getTaskTraceCall(taskId, callId
|
|
1430
|
-
this.store.
|
|
1426
|
+
getTaskTraceCall(taskId, callId) {
|
|
1427
|
+
this.store.getTaskWorkId(taskId);
|
|
1431
1428
|
const row = this.store.db.get(`SELECT trace.initial_messages_json, trace.rounds_json, trace.created_at, trace.updated_at
|
|
1432
1429
|
FROM ai_calls call JOIN ai_call_traces trace ON trace.call_id = call.id AND trace.task_id = call.task_id
|
|
1433
1430
|
WHERE call.id = ? AND call.task_id = ?`, callId, taskId);
|
|
@@ -1435,30 +1432,13 @@ export class AiManager {
|
|
|
1435
1432
|
throw notFound("AI 调用追踪");
|
|
1436
1433
|
const initialMessages = json(stringValue(row, "initial_messages_json"), []);
|
|
1437
1434
|
const rounds = json(stringValue(row, "rounds_json"), []);
|
|
1438
|
-
if (full) {
|
|
1439
|
-
return {
|
|
1440
|
-
taskId,
|
|
1441
|
-
callId,
|
|
1442
|
-
mode: "full",
|
|
1443
|
-
trace: {
|
|
1444
|
-
initialMessages,
|
|
1445
|
-
rounds,
|
|
1446
|
-
createdAt: stringValue(row, "created_at"),
|
|
1447
|
-
updatedAt: stringValue(row, "updated_at")
|
|
1448
|
-
}
|
|
1449
|
-
};
|
|
1450
|
-
}
|
|
1451
|
-
const preview = previewTaskTraceMessages(initialMessages);
|
|
1452
1435
|
return {
|
|
1453
1436
|
taskId,
|
|
1454
1437
|
callId,
|
|
1455
|
-
mode: "
|
|
1456
|
-
previewLimit: TASK_TRACE_PREVIEW_CHARACTER_LIMIT,
|
|
1457
|
-
truncated: preview.truncated,
|
|
1458
|
-
totalPromptChars: preview.totalChars,
|
|
1438
|
+
mode: "full",
|
|
1459
1439
|
trace: {
|
|
1460
|
-
initialMessages
|
|
1461
|
-
rounds
|
|
1440
|
+
initialMessages,
|
|
1441
|
+
rounds,
|
|
1462
1442
|
createdAt: stringValue(row, "created_at"),
|
|
1463
1443
|
updatedAt: stringValue(row, "updated_at")
|
|
1464
1444
|
}
|
|
@@ -1754,7 +1734,7 @@ export class AiManager {
|
|
|
1754
1734
|
return this.contextBuilder.buildPlan(input.workId, input.scope, workContextBudgetTokens, bookSummaryMaximumTokens, input.instruction);
|
|
1755
1735
|
}
|
|
1756
1736
|
buildContext(input, model) {
|
|
1757
|
-
return this.buildContextPlan(input, model).context;
|
|
1737
|
+
return collapseAiBlankLines(this.buildContextPlan(input, model).context);
|
|
1758
1738
|
}
|
|
1759
1739
|
enabledAgentToolIds(workId, taskType, requestedToolIds) {
|
|
1760
1740
|
if (taskType !== "chat" && requestedToolIds === undefined)
|
|
@@ -1862,7 +1842,7 @@ export class AiManager {
|
|
|
1862
1842
|
const chapter = this.store.getChapter(chapterId);
|
|
1863
1843
|
if (chapter.workId !== workId)
|
|
1864
1844
|
return { chapterId, error: { code: "CHAPTER_WORK_MISMATCH", message: "The requested chapter belongs to a different work." } };
|
|
1865
|
-
const content = String(chapter.content);
|
|
1845
|
+
const content = collapseAiBlankLines(String(chapter.content));
|
|
1866
1846
|
const excerpt = content.slice(0, Math.max(0, remainingChars));
|
|
1867
1847
|
remainingChars -= excerpt.length;
|
|
1868
1848
|
return { chapterId, title: chapter.title, versionNo: chapter.versionNo, ...(include !== "content" ? { summary: summaries.get(chapterId) ?? "" } : {}), ...(include !== "summary" ? { content: excerpt, contentTruncated: excerpt.length < content.length } : {}) };
|
|
@@ -1924,7 +1904,7 @@ export class AiManager {
|
|
|
1924
1904
|
const section = this.store.getCharacterProfileSection(sectionId);
|
|
1925
1905
|
if (section.workId !== workId)
|
|
1926
1906
|
return { sectionId, error: { code: "CHARACTER_SECTION_WORK_MISMATCH", message: "The requested character section belongs to a different work." } };
|
|
1927
|
-
const content = String(section.contentMarkdown);
|
|
1907
|
+
const content = collapseAiBlankLines(String(section.contentMarkdown));
|
|
1928
1908
|
const excerpt = content.slice(0, Math.max(0, remainingChars));
|
|
1929
1909
|
remainingChars -= excerpt.length;
|
|
1930
1910
|
const character = this.store.getCharacter(String(section.characterId));
|
|
@@ -1985,14 +1965,14 @@ export class AiManager {
|
|
|
1985
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,
|
|
1986
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);
|
|
1987
1967
|
if (input.taskId) {
|
|
1988
|
-
this.store.db.run(`INSERT INTO ai_call_traces (call_id, task_id, initial_messages_json, rounds_json, created_at, updated_at)
|
|
1989
|
-
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);
|
|
1990
1970
|
}
|
|
1991
1971
|
});
|
|
1992
1972
|
const saveTrace = () => {
|
|
1993
1973
|
if (!input.taskId)
|
|
1994
1974
|
return;
|
|
1995
|
-
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);
|
|
1996
1976
|
};
|
|
1997
1977
|
const callStartedAt = process.hrtime.bigint();
|
|
1998
1978
|
logger.info("ai.call.started", {
|
|
@@ -3317,10 +3297,141 @@ export class AiManager {
|
|
|
3317
3297
|
}
|
|
3318
3298
|
};
|
|
3319
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
|
+
}
|
|
3320
3430
|
async runRelationshipAnalysis(workId, scope, modelId, taskId) {
|
|
3321
3431
|
const characters = this.store.listCharacters(workId);
|
|
3322
3432
|
if (characters.length < 2)
|
|
3323
3433
|
throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
|
|
3434
|
+
const settingsOnly = scope.type === "settings";
|
|
3324
3435
|
const selectedCharacterIds = new Set(scope.characterIds ?? []);
|
|
3325
3436
|
for (const characterId of selectedCharacterIds) {
|
|
3326
3437
|
const character = characters.find((item) => item.id === characterId);
|
|
@@ -3332,33 +3443,87 @@ export class AiManager {
|
|
|
3332
3443
|
.filter((character) => selectedCharacterIds.has(String(character.id)))
|
|
3333
3444
|
.map((character) => `${String(character.id)} | ${String(character.name)}`)
|
|
3334
3445
|
.join("\n");
|
|
3335
|
-
const
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
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
|
+
}
|
|
3339
3484
|
const concurrency = this.configuredConcurrency(workId, "relationship-analysis", modelId);
|
|
3340
3485
|
const roster = characters.map((character) => {
|
|
3341
3486
|
const aliases = character.aliases.filter((alias) => this.isSafeGlobalAlias(alias));
|
|
3342
3487
|
return `${String(character.id)} | ${String(character.name)}${aliases.length ? ` | 别名:${aliases.join("、")}` : ""}`;
|
|
3343
3488
|
}).join("\n");
|
|
3344
3489
|
const rawCandidates = [];
|
|
3490
|
+
const chapterEvidenceCandidates = [];
|
|
3491
|
+
const settingCandidates = [];
|
|
3345
3492
|
const callIds = [];
|
|
3346
|
-
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) => {
|
|
3347
3511
|
const generated = await this.generateTaggedJson({
|
|
3348
3512
|
workId,
|
|
3349
3513
|
taskId,
|
|
3350
3514
|
taskType: "relationship-analysis",
|
|
3351
3515
|
signal: this.taskSignal(taskId),
|
|
3352
3516
|
maxAttempts,
|
|
3353
|
-
scope:
|
|
3354
|
-
type: "
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3517
|
+
scope: chunk.sourceKind === "setting"
|
|
3518
|
+
? { type: "settings", selection: chunk.text }
|
|
3519
|
+
: {
|
|
3520
|
+
type: "selection",
|
|
3521
|
+
selection: chunk.text,
|
|
3522
|
+
...(targeted ? { suppressAutomaticContext: true } : {})
|
|
3523
|
+
},
|
|
3359
3524
|
...(modelId ? { modelId } : {}),
|
|
3360
3525
|
parameters: { temperature: 0.1 },
|
|
3361
|
-
instruction: targeted ? [
|
|
3526
|
+
instruction: chunk.sourceKind === "setting" ? settingsInstruction : targeted ? [
|
|
3362
3527
|
"你是定向人物关系证据收集器。本阶段只建立跨章节证据账本,不下最终关系结论。",
|
|
3363
3528
|
"被分析角色:",
|
|
3364
3529
|
targetedRoster,
|
|
@@ -3403,9 +3568,11 @@ export class AiManager {
|
|
|
3403
3568
|
"24. 共同执行一次任务、同属一个组织、在同一集体场景中被感谢或落泪、替第三人转发消息,都不能单独证明同事、朋友或盟友。此类关系必须有原文明示身份,或至少两个不同章节的持续互动证据。"
|
|
3404
3569
|
].join("\n"),
|
|
3405
3570
|
extraSystemPrompt: [
|
|
3406
|
-
|
|
3407
|
-
? "
|
|
3408
|
-
:
|
|
3571
|
+
chunk.sourceKind === "setting"
|
|
3572
|
+
? "本次只允许使用提供的系统设定条目。每条结论都必须能回溯到对应 settingId 的原文引文。"
|
|
3573
|
+
: targeted
|
|
3574
|
+
? "你正在为指定角色收集可审计的跨章节关系线索。不得在证据收集阶段把单次互动直接判定为长期关系。"
|
|
3575
|
+
: "关系候选必须可审计。严禁把梦境伴侣、醉后梦话、单次约定、同章共现、礼称、同族归属、救援照护或类比提及写成现实长期关系。逐句校验说话人和关系方向。",
|
|
3409
3576
|
scope.additionalPrompt?.trim() ? `作者追加的关系分析提示:\n${scope.additionalPrompt.trim()}` : ""
|
|
3410
3577
|
].filter(Boolean).join("\n\n")
|
|
3411
3578
|
});
|
|
@@ -3419,26 +3586,32 @@ export class AiManager {
|
|
|
3419
3586
|
};
|
|
3420
3587
|
const chunkResults = await this.processChunks(chunks, concurrency, async (chunk) => {
|
|
3421
3588
|
if (taskId && this.store.getTask(taskId).status !== "running") {
|
|
3422
|
-
return { candidates: [], callIds: [], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
3589
|
+
return { sourceKind: chunk.sourceKind, candidates: [], callIds: [], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
3423
3590
|
}
|
|
3424
3591
|
try {
|
|
3425
|
-
const extracted = await extractChunk(chunk
|
|
3426
|
-
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 };
|
|
3427
3594
|
}
|
|
3428
3595
|
catch {
|
|
3596
|
+
if (chunk.sourceKind === "setting")
|
|
3597
|
+
throw new AppError(502, "AI_SETTINGS_BATCH_FAILED", "设定数据人物关系分析批次失败");
|
|
3429
3598
|
const segments = this.splitMarkedChapters(chunk.text);
|
|
3430
|
-
|
|
3599
|
+
const fallback = await this.runChapterSegmentFallback(segments, taskId, async (text, maxAttempts) => extractChunk({ sourceKind: "chapter", text }, maxAttempts), undefined, concurrency);
|
|
3600
|
+
return { sourceKind: chunk.sourceKind, ...fallback };
|
|
3431
3601
|
}
|
|
3432
3602
|
}, (completed) => {
|
|
3433
3603
|
if (taskId && this.store.getTask(taskId).status === "running") {
|
|
3434
|
-
const maximumProgress = targeted ? 72 : 92;
|
|
3604
|
+
const maximumProgress = targeted && !settingsOnly ? 72 : 92;
|
|
3435
3605
|
this.store.updateTask(taskId, { status: "running", progress: Math.min(maximumProgress, 5 + Math.round(completed / chunks.length * (maximumProgress - 5))) });
|
|
3436
3606
|
}
|
|
3437
3607
|
});
|
|
3438
3608
|
let fallbackSegmentCount = 0;
|
|
3439
3609
|
let policyOmittedSegmentCount = 0;
|
|
3440
3610
|
for (const result of chunkResults) {
|
|
3441
|
-
|
|
3611
|
+
if (result.sourceKind === "setting")
|
|
3612
|
+
settingCandidates.push(...result.candidates);
|
|
3613
|
+
else
|
|
3614
|
+
chapterEvidenceCandidates.push(...result.candidates);
|
|
3442
3615
|
callIds.push(...result.callIds);
|
|
3443
3616
|
fallbackSegmentCount += result.fallbackSegmentCount;
|
|
3444
3617
|
policyOmittedSegmentCount += result.policyOmittedSegmentCount;
|
|
@@ -3453,11 +3626,13 @@ export class AiManager {
|
|
|
3453
3626
|
batchCount: chunks.length
|
|
3454
3627
|
});
|
|
3455
3628
|
}
|
|
3456
|
-
|
|
3629
|
+
if (!targeted)
|
|
3630
|
+
rawCandidates.push(...chapterEvidenceCandidates, ...settingCandidates);
|
|
3631
|
+
const targetedEvidenceCount = targeted ? chapterEvidenceCandidates.length + settingCandidates.length : 0;
|
|
3457
3632
|
let aggregationBatchCount = 0;
|
|
3458
|
-
if (targeted &&
|
|
3633
|
+
if (targeted && !settingsOnly && chapterEvidenceCandidates.length > 0) {
|
|
3459
3634
|
const evidenceGroups = new Map();
|
|
3460
|
-
for (const evidence of
|
|
3635
|
+
for (const evidence of chapterEvidenceCandidates) {
|
|
3461
3636
|
const target = String(evidence.targetCharacterId ?? "");
|
|
3462
3637
|
const related = String(evidence.relatedCharacterId ?? evidence.relatedReference ?? "unknown");
|
|
3463
3638
|
const key = `${target}|${related}`;
|
|
@@ -3490,9 +3665,7 @@ export class AiManager {
|
|
|
3490
3665
|
maxAttempts: 2,
|
|
3491
3666
|
scope: {
|
|
3492
3667
|
type: "entities",
|
|
3493
|
-
|
|
3494
|
-
characterIds: [...selectedCharacterIds],
|
|
3495
|
-
excludeRelationshipConstraints: scope.replaceExistingRelationships === true
|
|
3668
|
+
suppressAutomaticContext: true
|
|
3496
3669
|
},
|
|
3497
3670
|
...(modelId ? { modelId } : {}),
|
|
3498
3671
|
parameters: { temperature: 0.1 },
|
|
@@ -3532,10 +3705,14 @@ export class AiManager {
|
|
|
3532
3705
|
this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 72 + Math.round(completed / evidenceBatches.length * 20)) });
|
|
3533
3706
|
}
|
|
3534
3707
|
});
|
|
3535
|
-
rawCandidates.
|
|
3708
|
+
rawCandidates.push(...aggregationResults.flatMap((result) => result.candidates));
|
|
3536
3709
|
callIds.push(...aggregationResults.map((result) => result.callId));
|
|
3537
3710
|
}
|
|
3711
|
+
if (targeted)
|
|
3712
|
+
rawCandidates.push(...settingCandidates);
|
|
3538
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)}`;
|
|
3539
3716
|
const categories = new Set(["family", "social", "emotional", "conflict", "uncertain"]);
|
|
3540
3717
|
const merged = new Map();
|
|
3541
3718
|
const skipped = [];
|
|
@@ -3584,21 +3761,36 @@ export class AiManager {
|
|
|
3584
3761
|
[fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
|
|
3585
3762
|
const evidence = (Array.isArray(candidate.evidence) ? candidate.evidence : [])
|
|
3586
3763
|
.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
|
3587
|
-
.
|
|
3588
|
-
if (typeof item.
|
|
3589
|
-
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 [];
|
|
3590
3781
|
const chapter = chapterById.get(item.chapterId);
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
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
|
+
});
|
|
3600
3792
|
if (evidence.length === 0) {
|
|
3601
|
-
skipped.push({ index, reason: "
|
|
3793
|
+
skipped.push({ index, reason: "证据引文未在对应章节或设定条目命中" });
|
|
3602
3794
|
return;
|
|
3603
3795
|
}
|
|
3604
3796
|
const evidenceText = evidence.map((item) => String(item.quote)).join("\n");
|
|
@@ -3618,9 +3810,9 @@ export class AiManager {
|
|
|
3618
3810
|
}
|
|
3619
3811
|
}
|
|
3620
3812
|
if (category === "conflict" && subtype === "宿敌") {
|
|
3621
|
-
const
|
|
3813
|
+
const evidenceSources = new Set(evidence.map((item) => String(item.settingId ?? item.chapterId)));
|
|
3622
3814
|
const explicitlyLongRunning = /宿敌|世仇|死敌|多年|长期|世代|一直.{0,24}(?:敌|威胁|对抗|杀手)|远古.{0,16}(?:战|敌)|多次.{0,16}(?:交战|对抗|冲突)/u.test(evidenceText);
|
|
3623
|
-
if (
|
|
3815
|
+
if (evidenceSources.size < 2 && !explicitlyLongRunning)
|
|
3624
3816
|
subtype = "战时敌对";
|
|
3625
3817
|
}
|
|
3626
3818
|
const key = [fromCharacterId, toCharacterId, category, this.normalizeReference(subtype), directed ? "1" : "0"].join("|");
|
|
@@ -3629,9 +3821,9 @@ export class AiManager {
|
|
|
3629
3821
|
current.confidence = Math.max(current.confidence, confidence);
|
|
3630
3822
|
current.currentStatus = currentStatus || current.currentStatus;
|
|
3631
3823
|
current.keywords = [...new Set([...current.keywords, ...keywords])].slice(0, 8);
|
|
3632
|
-
const seenEvidence = new Set(current.evidence.map(
|
|
3824
|
+
const seenEvidence = new Set(current.evidence.map(relationshipEvidenceKey));
|
|
3633
3825
|
for (const item of evidence) {
|
|
3634
|
-
const evidenceKey =
|
|
3826
|
+
const evidenceKey = relationshipEvidenceKey(item);
|
|
3635
3827
|
if (!seenEvidence.has(evidenceKey))
|
|
3636
3828
|
current.evidence.push(item);
|
|
3637
3829
|
}
|
|
@@ -3656,20 +3848,30 @@ export class AiManager {
|
|
|
3656
3848
|
const durablePeerSubtype = /同事|同僚|共事|搭档|伙伴|朋友|好友|挚友|老友|旧友|战友|盟友|同盟|联盟/u.test(candidate.subtype);
|
|
3657
3849
|
if (candidate.category !== "social" || !durablePeerSubtype)
|
|
3658
3850
|
continue;
|
|
3659
|
-
const
|
|
3851
|
+
const evidenceSources = new Set(candidate.evidence.map((item) => String(item.settingId ?? item.chapterId)));
|
|
3660
3852
|
const evidenceText = candidate.evidence.map((item) => String(item.quote)).join("\n");
|
|
3661
3853
|
const explicitlyLongRunning = /同事|同僚|共事|搭档|伙伴|朋友|好友|挚友|老友|旧友|老朋友|战友|盟友|同盟|联盟|结盟|缔盟|盟约|旧识|好久不见|多年|长期|几十年|经常|往日|一直.{0,16}(?:合作|支援|互助|并肩)/u.test(evidenceText);
|
|
3662
|
-
if (
|
|
3854
|
+
if (evidenceSources.size >= 2 || explicitlyLongRunning)
|
|
3663
3855
|
continue;
|
|
3664
|
-
skipped.push({ index: -1, reason:
|
|
3856
|
+
skipped.push({ index: -1, reason: candidate.evidence.every((item) => item.contextType === "setting")
|
|
3857
|
+
? `“${candidate.subtype}”缺少设定集中的明确长期关系表述`
|
|
3858
|
+
: `“${candidate.subtype}”缺少明确身份或跨章长期互动证据` });
|
|
3665
3859
|
merged.delete(key);
|
|
3666
3860
|
}
|
|
3667
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
|
+
};
|
|
3668
3871
|
let replacedRelationshipCount = 0;
|
|
3872
|
+
if (!this.taskCanCommit(taskId))
|
|
3873
|
+
return { interrupted: true, callIds };
|
|
3669
3874
|
this.store.db.transaction(() => {
|
|
3670
|
-
if (!targeted && scope.type === "book") {
|
|
3671
|
-
this.store.db.run("DELETE FROM relationships WHERE work_id = ? AND confirmation_status = 'pending' AND locked = 0", workId);
|
|
3672
|
-
}
|
|
3673
3875
|
if (targeted && scope.replaceExistingRelationships === true) {
|
|
3674
3876
|
const relationshipsToReplace = this.store.listRelationships(workId).filter((relationship) => selectedCharacterIds.has(String(relationship.fromCharacterId)) || selectedCharacterIds.has(String(relationship.toCharacterId)));
|
|
3675
3877
|
for (const relationship of relationshipsToReplace)
|
|
@@ -3677,6 +3879,7 @@ export class AiManager {
|
|
|
3677
3879
|
replacedRelationshipCount = relationshipsToReplace.length;
|
|
3678
3880
|
}
|
|
3679
3881
|
const existing = this.store.listRelationships(workId).filter((relationship) => relationship.confirmationStatus !== "rejected");
|
|
3882
|
+
const appendOnly = scope.replaceExistingRelationships !== true;
|
|
3680
3883
|
const unorderedPairKey = (fromCharacterId, toCharacterId) => {
|
|
3681
3884
|
const pair = [String(fromCharacterId), String(toCharacterId)].sort((left, right) => left.localeCompare(right));
|
|
3682
3885
|
return `${pair[0]}|${pair[1]}`;
|
|
@@ -3794,11 +3997,15 @@ export class AiManager {
|
|
|
3794
3997
|
});
|
|
3795
3998
|
if (duplicateIndex >= 0) {
|
|
3796
3999
|
const duplicate = existing[duplicateIndex];
|
|
4000
|
+
if (appendOnly) {
|
|
4001
|
+
skipped.push({ index: -1, reason: `已有相同的“${candidate.subtype}”关系,追加模式不更新` });
|
|
4002
|
+
continue;
|
|
4003
|
+
}
|
|
3797
4004
|
if (duplicate.confirmationStatus === "pending" && duplicate.locked !== true) {
|
|
3798
4005
|
const mergedEvidence = [...(duplicate.evidence ?? [])];
|
|
3799
|
-
const seenEvidence = new Set(mergedEvidence.map(
|
|
4006
|
+
const seenEvidence = new Set(mergedEvidence.map(relationshipEvidenceKey));
|
|
3800
4007
|
for (const item of candidate.evidence) {
|
|
3801
|
-
const evidenceKey =
|
|
4008
|
+
const evidenceKey = relationshipEvidenceKey(item);
|
|
3802
4009
|
if (!seenEvidence.has(evidenceKey))
|
|
3803
4010
|
mergedEvidence.push(item);
|
|
3804
4011
|
}
|
|
@@ -3810,6 +4017,10 @@ export class AiManager {
|
|
|
3810
4017
|
timeRange: candidate.timeRange,
|
|
3811
4018
|
evidence: mergedEvidence
|
|
3812
4019
|
}, "analysis", taskId ?? null, "AI 合并关系证据");
|
|
4020
|
+
recordRelationshipOutcome("updated", existing[duplicateIndex]);
|
|
4021
|
+
}
|
|
4022
|
+
else {
|
|
4023
|
+
recordRelationshipOutcome("unchanged", duplicate);
|
|
3813
4024
|
}
|
|
3814
4025
|
if (candidatePeerStrength > 0) {
|
|
3815
4026
|
for (let index = existing.length - 1; index >= 0; index -= 1) {
|
|
@@ -3840,12 +4051,12 @@ export class AiManager {
|
|
|
3840
4051
|
&& peerSocialStrength(relationship) > 0
|
|
3841
4052
|
&& peerSocialStrength(relationship) < candidatePeerStrength)
|
|
3842
4053
|
: -1;
|
|
3843
|
-
if (weakerExistingPeerIndex >= 0) {
|
|
4054
|
+
if (weakerExistingPeerIndex >= 0 && !appendOnly) {
|
|
3844
4055
|
const weaker = existing[weakerExistingPeerIndex];
|
|
3845
4056
|
const mergedEvidence = [...(weaker.evidence ?? [])];
|
|
3846
|
-
const seenEvidence = new Set(mergedEvidence.map(
|
|
4057
|
+
const seenEvidence = new Set(mergedEvidence.map(relationshipEvidenceKey));
|
|
3847
4058
|
for (const item of candidate.evidence) {
|
|
3848
|
-
const evidenceKey =
|
|
4059
|
+
const evidenceKey = relationshipEvidenceKey(item);
|
|
3849
4060
|
if (!seenEvidence.has(evidenceKey))
|
|
3850
4061
|
mergedEvidence.push(item);
|
|
3851
4062
|
}
|
|
@@ -3857,18 +4068,60 @@ export class AiManager {
|
|
|
3857
4068
|
timeRange: candidate.timeRange,
|
|
3858
4069
|
evidence: mergedEvidence
|
|
3859
4070
|
}, "analysis", taskId ?? null, "AI 更新关系强度");
|
|
4071
|
+
recordRelationshipOutcome("updated", existing[weakerExistingPeerIndex]);
|
|
3860
4072
|
continue;
|
|
3861
4073
|
}
|
|
3862
4074
|
const relationship = this.store.createRelationship(workId, { ...candidate, confirmationStatus: "pending", locked: false }, "analysis", taskId ?? null);
|
|
3863
4075
|
relationshipIds.push(String(relationship.id));
|
|
4076
|
+
recordRelationshipOutcome("created", relationship);
|
|
3864
4077
|
existing.push(relationship);
|
|
3865
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
|
+
};
|
|
3866
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;
|
|
3867
4117
|
this.store.audit(workId, "relationship.analysis.completed", "work", workId, {
|
|
3868
4118
|
batchCount: chunks.length,
|
|
3869
4119
|
coveredChapterCount: chapters.length,
|
|
4120
|
+
coveredSettingCount: settings.length,
|
|
3870
4121
|
rawCandidateCount: rawCandidates.length,
|
|
3871
4122
|
savedCount: relationshipIds.length,
|
|
4123
|
+
updatedCount,
|
|
4124
|
+
unchangedCount,
|
|
3872
4125
|
skippedCount: skipped.length,
|
|
3873
4126
|
fallbackSegmentCount,
|
|
3874
4127
|
policyOmittedSegmentCount,
|
|
@@ -3881,10 +4134,25 @@ export class AiManager {
|
|
|
3881
4134
|
return {
|
|
3882
4135
|
relationshipIds,
|
|
3883
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
|
+
},
|
|
3884
4151
|
rawCandidateCount: rawCandidates.length,
|
|
3885
4152
|
skipped,
|
|
3886
4153
|
batchCount: chunks.length,
|
|
3887
4154
|
coveredChapterCount: chapters.length,
|
|
4155
|
+
coveredSettingCount: settings.length,
|
|
3888
4156
|
fallbackSegmentCount,
|
|
3889
4157
|
policyOmittedSegmentCount,
|
|
3890
4158
|
targetedCharacterIds: [...selectedCharacterIds],
|
|
@@ -3980,6 +4248,29 @@ export class AiManager {
|
|
|
3980
4248
|
flush();
|
|
3981
4249
|
return chunks;
|
|
3982
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
|
+
}
|
|
3983
4274
|
splitMarkedChapters(text) {
|
|
3984
4275
|
const segments = text.match(/<CHAPTER\b[^>]*>[\s\S]*?<\/CHAPTER>/gu) ?? [];
|
|
3985
4276
|
return segments.length > 0 ? segments : [text];
|