@musnows/scriverse 0.3.5 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai.js +223 -15
- package/dist/ai.js.map +1 -1
- package/dist/app.js +129 -9
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +162 -0
- package/dist/attachment-storage.js.map +1 -0
- package/dist/cli-contract.js +4 -4
- package/dist/cli-contract.js.map +1 -1
- package/dist/database.js +219 -1
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +328 -29
- package/dist/public/index.html +2 -2
- package/dist/public/markdown.js +16 -1
- package/dist/public/race-hierarchy.js +49 -0
- package/dist/public/relationship-graph.js +205 -31
- package/dist/public/styles.css +88 -3
- package/dist/server-runtime.js +1 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +565 -21
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +7 -0
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/ai.js
CHANGED
|
@@ -18,9 +18,10 @@ export function aiErrorForLog(error) {
|
|
|
18
18
|
const allowedParameters = new Set(["temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty", "seed"]);
|
|
19
19
|
const DEFAULT_MAX_TOKENS = 32_000;
|
|
20
20
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
21
|
-
const AGENT_TOOL_IDS = ["story_index", "read_chapters", "grep", "query_story_knowledge"];
|
|
21
|
+
const AGENT_TOOL_IDS = ["story_index", "read_chapters", "grep", "query_story_knowledge", "read_character_sections"];
|
|
22
22
|
const MAX_AGENT_TOOL_ROUNDS = 6;
|
|
23
23
|
const MAX_AGENT_TOOL_CALLS = 12;
|
|
24
|
+
const MAX_CONFIGURED_AGENT_TOOL_CALLS = 48;
|
|
24
25
|
const storyIndexArguments = z.object({
|
|
25
26
|
offset: z.number().int().min(0).max(10_000).default(0),
|
|
26
27
|
limit: z.number().int().min(1).max(50).default(20)
|
|
@@ -37,6 +38,10 @@ const queryStoryKnowledgeArguments = z.object({
|
|
|
37
38
|
query: z.string().trim().min(1).max(200),
|
|
38
39
|
categories: z.array(z.enum(["setting", "character", "race", "organization", "timeline", "relationship", "outline", "foreshadow"])).max(8).default([])
|
|
39
40
|
}).strict();
|
|
41
|
+
const readCharacterSectionsArguments = z.object({
|
|
42
|
+
sectionIds: z.array(z.string().min(1).max(300)).min(1).max(3),
|
|
43
|
+
include: z.enum(["summary", "content", "both"]).default("both")
|
|
44
|
+
}).strict();
|
|
40
45
|
const AGENT_TOOL_DEFINITIONS = {
|
|
41
46
|
story_index: {
|
|
42
47
|
type: "function",
|
|
@@ -66,9 +71,17 @@ const AGENT_TOOL_DEFINITIONS = {
|
|
|
66
71
|
type: "function",
|
|
67
72
|
function: {
|
|
68
73
|
name: "query_story_knowledge",
|
|
69
|
-
description: "
|
|
74
|
+
description: "按关键词查询作品知识:设定、人物及其 Markdown 档案章节、种族、组织、时间线、关系、大纲和伏笔。结果为简要匹配项;人物结果包含 sectionId 时可调用 read_character_sections 精读。",
|
|
70
75
|
parameters: { type: "object", properties: { query: { type: "string", minLength: 1, maxLength: 200 }, categories: { type: "array", items: { type: "string", enum: ["setting", "character", "race", "organization", "timeline", "relationship", "outline", "foreshadow"] }, maxItems: 8 } }, required: ["query"], additionalProperties: false }
|
|
71
76
|
}
|
|
77
|
+
},
|
|
78
|
+
read_character_sections: {
|
|
79
|
+
type: "function",
|
|
80
|
+
function: {
|
|
81
|
+
name: "read_character_sections",
|
|
82
|
+
description: "读取指定人物 Markdown 档案章节的摘要或原文。先通过 query_story_knowledge 获取 sectionId;每次最多读取 3 个章节。",
|
|
83
|
+
parameters: { type: "object", properties: { sectionIds: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 3 }, include: { type: "string", enum: ["summary", "content", "both"] } }, required: ["sectionIds"], additionalProperties: false }
|
|
84
|
+
}
|
|
72
85
|
}
|
|
73
86
|
};
|
|
74
87
|
export function estimateAiTokens(value) {
|
|
@@ -476,7 +489,13 @@ export class ContextBuilder {
|
|
|
476
489
|
constraints.push(`选定角色:\n${characters
|
|
477
490
|
.map((item) => {
|
|
478
491
|
const attributes = item.attributes;
|
|
479
|
-
|
|
492
|
+
const race = item.race;
|
|
493
|
+
const racePath = race?.lineage?.map((entry) => String(entry.name ?? "")).filter(Boolean).join(" / ") || String(item.species || attributes.species) || "未填写";
|
|
494
|
+
const raceSettings = race?.effectiveSettings?.map((setting) => ({ source: String(setting.sourceRaceName ?? ""), value: String(setting.value ?? "") })) ?? [];
|
|
495
|
+
const profile = { ...item.profile };
|
|
496
|
+
delete profile.sections;
|
|
497
|
+
const sectionCatalog = this.store.listCharacterProfileSectionCatalog(String(item.id));
|
|
498
|
+
return `- ${String(item.name)};种族路径=${racePath};种族共同设定=${JSON.stringify(raceSettings)};别名=${JSON.stringify(item.aliases)};属性=${JSON.stringify(item.attributes)};当前状态=${JSON.stringify(item.currentState)};设定=${JSON.stringify(profile)};Markdown 档案目录=${JSON.stringify(sectionCatalog)}`;
|
|
480
499
|
})
|
|
481
500
|
.join("\n")}`);
|
|
482
501
|
}
|
|
@@ -1138,6 +1157,9 @@ export class AiManager {
|
|
|
1138
1157
|
else if (taskType === "character-extraction" || taskType === "character-summary") {
|
|
1139
1158
|
result = await this.runCharacterExtraction(workId, scope, modelId, taskId);
|
|
1140
1159
|
}
|
|
1160
|
+
else if (taskType === "character-identity-audit") {
|
|
1161
|
+
result = await this.runCharacterIdentityAudit(workId, scope, modelId, taskId);
|
|
1162
|
+
}
|
|
1141
1163
|
else if (taskType === "timeline-analysis") {
|
|
1142
1164
|
result = await this.runTimelineAnalysis(workId, scope, modelId, taskId);
|
|
1143
1165
|
}
|
|
@@ -1340,12 +1362,12 @@ export class AiManager {
|
|
|
1340
1362
|
buildMessages(input, context) {
|
|
1341
1363
|
const platformPrompt = String(this.store.getPlatformAiSettings().systemPrompt ?? "").trim();
|
|
1342
1364
|
const workPrompt = String(this.store.getWorkAiSettings(input.workId).systemPrompt ?? "").trim();
|
|
1343
|
-
const enabledToolIds = this.enabledAgentToolIds(input.workId, input.taskType);
|
|
1365
|
+
const enabledToolIds = this.enabledAgentToolIds(input.workId, input.taskType, input.agentToolIds);
|
|
1344
1366
|
const toolGuidance = enabledToolIds.length > 0
|
|
1345
1367
|
? [
|
|
1346
1368
|
`当前可用作品查询工具:${enabledToolIds.join("、")}。`,
|
|
1347
1369
|
"当作者询问当前作品、项目、章节、情节、人物、关系、世界观或设定,而预加载上下文为空或不足时,必须先调用工具主动查询;不得直接声称没有上下文,也不得先要求作者补充本系统已经能够查询的信息。",
|
|
1348
|
-
"整体介绍、作品基本信息、目录或章节定位优先调用 story_index;按关键字定位正文段落时调用 grep;已知章节 ID 且需要原文事实或精确措辞时调用 read_chapters;查询设定、人物、组织、时间线、关系、大纲或伏笔时调用 query_story_knowledge。",
|
|
1370
|
+
"整体介绍、作品基本信息、目录或章节定位优先调用 story_index;按关键字定位正文段落时调用 grep;已知章节 ID 且需要原文事实或精确措辞时调用 read_chapters;查询设定、人物、组织、时间线、关系、大纲或伏笔时调用 query_story_knowledge;人物匹配结果包含 sectionId 且需要背景故事、能力或经历原文时调用 read_character_sections。",
|
|
1349
1371
|
"根据问题选择最少且必要的工具。工具结果仍不足时才说明未知,并明确已经查询过什么;不要重复无效调用。"
|
|
1350
1372
|
].join("\n")
|
|
1351
1373
|
: "";
|
|
@@ -1393,15 +1415,16 @@ export class AiManager {
|
|
|
1393
1415
|
buildContext(input, model) {
|
|
1394
1416
|
return this.buildContextPlan(input, model).context;
|
|
1395
1417
|
}
|
|
1396
|
-
enabledAgentToolIds(workId, taskType) {
|
|
1397
|
-
if (taskType !== "chat")
|
|
1418
|
+
enabledAgentToolIds(workId, taskType, requestedToolIds) {
|
|
1419
|
+
if (taskType !== "chat" && requestedToolIds === undefined)
|
|
1398
1420
|
return [];
|
|
1399
1421
|
const enabled = new Set(this.store.getWorkAiSettings(workId).agentTools
|
|
1400
1422
|
.filter((item) => typeof item === "string" && AGENT_TOOL_IDS.includes(item)));
|
|
1401
|
-
|
|
1423
|
+
const requested = requestedToolIds ? new Set(requestedToolIds) : null;
|
|
1424
|
+
return AGENT_TOOL_IDS.filter((toolId) => enabled.has(toolId) && (!requested || requested.has(toolId)));
|
|
1402
1425
|
}
|
|
1403
|
-
enabledAgentTools(workId, taskType) {
|
|
1404
|
-
return this.enabledAgentToolIds(workId, taskType).map((toolId) => AGENT_TOOL_DEFINITIONS[toolId]);
|
|
1426
|
+
enabledAgentTools(workId, taskType, requestedToolIds) {
|
|
1427
|
+
return this.enabledAgentToolIds(workId, taskType, requestedToolIds).map((toolId) => AGENT_TOOL_DEFINITIONS[toolId]);
|
|
1405
1428
|
}
|
|
1406
1429
|
executeAgentTool(workId, toolCall) {
|
|
1407
1430
|
const name = toolCall.function.name;
|
|
@@ -1429,7 +1452,8 @@ export class AiManager {
|
|
|
1429
1452
|
: name === "read_chapters" ? readChaptersArguments
|
|
1430
1453
|
: name === "grep" ? grepArguments
|
|
1431
1454
|
: name === "query_story_knowledge" ? queryStoryKnowledgeArguments
|
|
1432
|
-
:
|
|
1455
|
+
: name === "read_character_sections" ? readCharacterSectionsArguments
|
|
1456
|
+
: null;
|
|
1433
1457
|
if (!schema) {
|
|
1434
1458
|
return {
|
|
1435
1459
|
id: toolCall.id,
|
|
@@ -1533,6 +1557,35 @@ export class AiManager {
|
|
|
1533
1557
|
].filter((item) => allowed.has(item.type) && (!categories.size || categories.has(item.type)) && `${item.title} ${item.snippet}`.toLocaleLowerCase("zh-CN").includes(query.toLocaleLowerCase("zh-CN"))).slice(0, 20);
|
|
1534
1558
|
return { id: toolCall.id, name, calledAt, arguments: { query, categories: categoryList }, status: "completed", result: { ok: true, data: { query, matches: [...matches, ...extra].slice(0, 30) } } };
|
|
1535
1559
|
}
|
|
1560
|
+
if (name === "read_character_sections") {
|
|
1561
|
+
const { sectionIds, include } = args;
|
|
1562
|
+
let remainingChars = 48_000;
|
|
1563
|
+
const sections = sectionIds.map((sectionId) => {
|
|
1564
|
+
try {
|
|
1565
|
+
const section = this.store.getCharacterProfileSection(sectionId);
|
|
1566
|
+
if (section.workId !== workId)
|
|
1567
|
+
return { sectionId, error: { code: "CHARACTER_SECTION_WORK_MISMATCH", message: "The requested character section belongs to a different work." } };
|
|
1568
|
+
const content = String(section.contentMarkdown);
|
|
1569
|
+
const excerpt = content.slice(0, Math.max(0, remainingChars));
|
|
1570
|
+
remainingChars -= excerpt.length;
|
|
1571
|
+
const character = this.store.getCharacter(String(section.characterId));
|
|
1572
|
+
return {
|
|
1573
|
+
sectionId,
|
|
1574
|
+
characterId: section.characterId,
|
|
1575
|
+
characterName: character.name,
|
|
1576
|
+
title: section.title,
|
|
1577
|
+
sectionType: section.sectionType,
|
|
1578
|
+
versionNo: section.versionNo,
|
|
1579
|
+
...(include !== "content" ? { summary: section.summary } : {}),
|
|
1580
|
+
...(include !== "summary" ? { contentMarkdown: excerpt, contentTruncated: excerpt.length < content.length } : {})
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
catch {
|
|
1584
|
+
return { sectionId, error: { code: "CHARACTER_SECTION_NOT_FOUND", message: "The requested character section was not found." } };
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
return { id: toolCall.id, name, calledAt, arguments: { sectionIds, include }, status: "completed", result: { ok: true, data: { sections, contentLimitChars: 48_000 } } };
|
|
1588
|
+
}
|
|
1536
1589
|
throw new Error(`Unhandled agent tool: ${name}`);
|
|
1537
1590
|
}
|
|
1538
1591
|
constrainParametersForContext(model, messages, parameters) {
|
|
@@ -1560,7 +1613,7 @@ export class AiManager {
|
|
|
1560
1613
|
const context = this.buildContext(input, model);
|
|
1561
1614
|
const preset = safeJsonObject(stringValue(model, "preset_json"));
|
|
1562
1615
|
const messages = this.buildMessages(input, context);
|
|
1563
|
-
const tools = input.disableTools ? [] : this.enabledAgentTools(input.workId, input.taskType);
|
|
1616
|
+
const tools = input.disableTools ? [] : this.enabledAgentTools(input.workId, input.taskType, input.agentToolIds);
|
|
1564
1617
|
const completionMessages = [...messages];
|
|
1565
1618
|
const parameters = this.constrainParametersForContext(model, messages, {
|
|
1566
1619
|
...this.sanitizeParameters({ ...preset, ...(input.parameters ?? {}), max_tokens: numberValue(provider, "max_tokens") || DEFAULT_MAX_TOKENS }),
|
|
@@ -1606,7 +1659,12 @@ export class AiManager {
|
|
|
1606
1659
|
const response = await this.outboundFetch(endpoint, {
|
|
1607
1660
|
method: "POST",
|
|
1608
1661
|
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
1609
|
-
body: JSON.stringify({
|
|
1662
|
+
body: JSON.stringify({
|
|
1663
|
+
model: stringValue(model, "model_id"),
|
|
1664
|
+
messages: completionMessages,
|
|
1665
|
+
...parameters,
|
|
1666
|
+
...(tools.length && toolChoice === "auto" ? { tools, tool_choice: "auto" } : {})
|
|
1667
|
+
}),
|
|
1610
1668
|
signal: controller.signal
|
|
1611
1669
|
});
|
|
1612
1670
|
return { ok: response.ok, status: response.status, body: await response.text() };
|
|
@@ -1660,6 +1718,7 @@ export class AiManager {
|
|
|
1660
1718
|
let choice = payload.choices?.[0];
|
|
1661
1719
|
const executedToolCalls = [];
|
|
1662
1720
|
const processSteps = [];
|
|
1721
|
+
const agentToolCallLimit = Math.round(clamp(input.agentToolCallLimit ?? MAX_AGENT_TOOL_CALLS, 1, MAX_CONFIGURED_AGENT_TOOL_CALLS));
|
|
1663
1722
|
const recordChoiceProcess = (currentChoice, round, includeIntermediate) => {
|
|
1664
1723
|
const reasoning = currentChoice?.message?.reasoning_content;
|
|
1665
1724
|
if (reasoning?.trim()) {
|
|
@@ -1679,8 +1738,8 @@ export class AiManager {
|
|
|
1679
1738
|
const round = toolRound + 1;
|
|
1680
1739
|
recordChoiceProcess(choice, round, true);
|
|
1681
1740
|
const toolCalls = choice.message.tool_calls;
|
|
1682
|
-
if (executedToolCalls.length + toolCalls.length >
|
|
1683
|
-
throw new Error(`AI requested more than ${
|
|
1741
|
+
if (executedToolCalls.length + toolCalls.length > agentToolCallLimit) {
|
|
1742
|
+
throw new Error(`AI requested more than ${agentToolCallLimit} tool calls in one response cycle.`);
|
|
1684
1743
|
}
|
|
1685
1744
|
const normalizedToolCalls = toolCalls.map((toolCall) => ({
|
|
1686
1745
|
...toolCall,
|
|
@@ -1705,6 +1764,12 @@ export class AiManager {
|
|
|
1705
1764
|
}
|
|
1706
1765
|
toolRound += 1;
|
|
1707
1766
|
const forceFinalAnswer = toolRound >= MAX_AGENT_TOOL_ROUNDS;
|
|
1767
|
+
if (forceFinalAnswer) {
|
|
1768
|
+
completionMessages.push({
|
|
1769
|
+
role: "user",
|
|
1770
|
+
content: "工具调用阶段已经结束,不得再请求任何工具。请立即根据已有工具结果生成最终答案,并严格遵守最初用户消息要求的输出格式。"
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1708
1773
|
payload = await requestCompletion(forceFinalAnswer ? "none" : "auto");
|
|
1709
1774
|
choice = payload.choices?.[0];
|
|
1710
1775
|
if (forceFinalAnswer && choice?.message?.tool_calls?.length) {
|
|
@@ -2265,6 +2330,149 @@ export class AiManager {
|
|
|
2265
2330
|
}
|
|
2266
2331
|
return { reviewIds, issueCount: reviewIds.length, callId: generated.callId };
|
|
2267
2332
|
}
|
|
2333
|
+
async runCharacterIdentityAudit(workId, scope, modelId, taskId) {
|
|
2334
|
+
const characters = this.store.listCharacters(workId);
|
|
2335
|
+
if (characters.length < 2)
|
|
2336
|
+
return { characterCount: characters.length, candidateCount: 0, reviewIds: [], skipped: [] };
|
|
2337
|
+
const requiredTools = ["query_story_knowledge", "grep", "read_chapters"];
|
|
2338
|
+
const enabledTools = new Set(this.enabledAgentToolIds(workId, "book-analysis", requiredTools));
|
|
2339
|
+
if (!enabledTools.has("query_story_knowledge") || !enabledTools.has("grep")) {
|
|
2340
|
+
throw new AppError(409, "AI_TOOLS_REQUIRED", "角色查重需要启用“查询作品知识”和“正文搜索”工具");
|
|
2341
|
+
}
|
|
2342
|
+
const roster = characters.map((character) => {
|
|
2343
|
+
const attributes = character.attributes;
|
|
2344
|
+
const profile = character.profile;
|
|
2345
|
+
const organizations = character.organizations.map((item) => String(item.name)).join("、");
|
|
2346
|
+
return [
|
|
2347
|
+
`ID=${String(character.id)}`,
|
|
2348
|
+
`主名=${String(character.name)}`,
|
|
2349
|
+
`别名=${character.aliases.join("、") || "无"}`,
|
|
2350
|
+
`种族=${String(character.species || "未知")}`,
|
|
2351
|
+
`身份=${String(attributes.identity ?? "未知")}`,
|
|
2352
|
+
`组织=${organizations || "无"}`,
|
|
2353
|
+
`简介=${String(profile.summary ?? "无")}`,
|
|
2354
|
+
`首次章节=${String(character.firstChapterId ?? "未知")}`
|
|
2355
|
+
].join(" | ");
|
|
2356
|
+
}).join("\n");
|
|
2357
|
+
const generated = await this.generateTaggedJson({
|
|
2358
|
+
workId,
|
|
2359
|
+
taskType: "book-analysis",
|
|
2360
|
+
signal: this.taskSignal(taskId),
|
|
2361
|
+
scope: scope.type === "none" ? scope : { type: "none" },
|
|
2362
|
+
...(modelId ? { modelId } : {}),
|
|
2363
|
+
parameters: { temperature: 0.1 },
|
|
2364
|
+
agentToolIds: requiredTools,
|
|
2365
|
+
agentToolCallLimit: 48,
|
|
2366
|
+
instruction: [
|
|
2367
|
+
"审核角色规范表,找出可能把同一个角色误建成两个档案的组合,最多输出 12 组。",
|
|
2368
|
+
"角色规范表:",
|
|
2369
|
+
roster,
|
|
2370
|
+
"你必须主动使用 query_story_knowledge 查询角色档案和关系,并使用 grep 分别搜索疑似组合两侧的主名或别名;需要上下文时再用 read_chapters。工具调用总数不得超过 48 次。",
|
|
2371
|
+
"不能仅凭名字相似判断同一人。角色彼此对话、互相提及、同时出现、身份或种族冲突,都是不同角色的强反证。",
|
|
2372
|
+
"只把有原文连续引文支持的 same 或 uncertain 组合放入结果;确认是不同角色的组合无需输出。",
|
|
2373
|
+
"输出 JSON 数组。每项字段:leftCharacterId、rightCharacterId、verdict(same/uncertain)、confidence(0到1)、reason、evidence(数组,每项含 chapterId、quote、supports)、contradictions(字符串数组)。",
|
|
2374
|
+
"quote 必须是原文连续引文且不超过 80 字;不得创造角色 ID、章节 ID 或证据。没有疑似重复角色时输出 []。"
|
|
2375
|
+
].join("\n"),
|
|
2376
|
+
extraSystemPrompt: "你是谨慎的角色身份消歧审核器。任何结论都只是待作者确认的建议,禁止自动合并角色。"
|
|
2377
|
+
});
|
|
2378
|
+
const toolNames = new Set(generated.toolCalls.filter((call) => call.status === "completed").map((call) => call.name));
|
|
2379
|
+
if (!toolNames.has("query_story_knowledge") || !toolNames.has("grep")) {
|
|
2380
|
+
throw new AppError(502, "CHARACTER_AUDIT_INCOMPLETE", "AI 未完成角色资料查询和正文搜索,本次查重结果未保存");
|
|
2381
|
+
}
|
|
2382
|
+
const extracted = extractJson(generated.content);
|
|
2383
|
+
if (!Array.isArray(extracted))
|
|
2384
|
+
throw new AppError(502, "AI_INVALID_JSON", "角色查重结果必须是数组");
|
|
2385
|
+
if (!this.taskCanCommit(taskId))
|
|
2386
|
+
return { interrupted: true, callId: generated.callId };
|
|
2387
|
+
const characterById = new Map(characters.map((character) => [String(character.id), character]));
|
|
2388
|
+
const existingReviews = this.store.listReviewItems(workId).filter((review) => review.itemType === "character-duplicate");
|
|
2389
|
+
const reviewedPairVersions = new Set(existingReviews.flatMap((review) => {
|
|
2390
|
+
const refs = review.entityRefs.flatMap((reference) => {
|
|
2391
|
+
if (!reference || typeof reference !== "object" || Array.isArray(reference))
|
|
2392
|
+
return [];
|
|
2393
|
+
const value = reference;
|
|
2394
|
+
return typeof value.id === "string" && typeof value.versionNo === "number" ? [{ id: value.id, versionNo: value.versionNo }] : [];
|
|
2395
|
+
}).sort((left, right) => left.id.localeCompare(right.id));
|
|
2396
|
+
return refs.length === 2 ? [`${refs[0]?.id}@${refs[0]?.versionNo}|${refs[1]?.id}@${refs[1]?.versionNo}`] : [];
|
|
2397
|
+
}));
|
|
2398
|
+
const seenPairs = new Set();
|
|
2399
|
+
const reviewIds = [];
|
|
2400
|
+
const skipped = [];
|
|
2401
|
+
for (const item of extracted) {
|
|
2402
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
2403
|
+
continue;
|
|
2404
|
+
const candidate = item;
|
|
2405
|
+
const leftId = typeof candidate.leftCharacterId === "string" ? candidate.leftCharacterId : "";
|
|
2406
|
+
const rightId = typeof candidate.rightCharacterId === "string" ? candidate.rightCharacterId : "";
|
|
2407
|
+
const left = characterById.get(leftId);
|
|
2408
|
+
const right = characterById.get(rightId);
|
|
2409
|
+
if (!left || !right || leftId === rightId) {
|
|
2410
|
+
skipped.push({ pair: `${leftId}/${rightId}`, reason: "角色引用无效" });
|
|
2411
|
+
continue;
|
|
2412
|
+
}
|
|
2413
|
+
const ordered = [left, right].sort((first, second) => String(first.id).localeCompare(String(second.id)));
|
|
2414
|
+
const pairKey = `${String(ordered[0]?.id)}@${Number(ordered[0]?.versionNo)}|${String(ordered[1]?.id)}@${Number(ordered[1]?.versionNo)}`;
|
|
2415
|
+
if (seenPairs.has(pairKey) || reviewedPairVersions.has(pairKey)) {
|
|
2416
|
+
skipped.push({ pair: pairKey, reason: "当前角色版本已经审核" });
|
|
2417
|
+
continue;
|
|
2418
|
+
}
|
|
2419
|
+
seenPairs.add(pairKey);
|
|
2420
|
+
const verdict = candidate.verdict === "same" || candidate.verdict === "uncertain" ? candidate.verdict : null;
|
|
2421
|
+
const confidence = clamp(typeof candidate.confidence === "number" ? candidate.confidence : 0, 0, 1);
|
|
2422
|
+
if (!verdict || confidence < 0.6) {
|
|
2423
|
+
skipped.push({ pair: pairKey, reason: "结论或置信度不足" });
|
|
2424
|
+
continue;
|
|
2425
|
+
}
|
|
2426
|
+
const evidence = (Array.isArray(candidate.evidence) ? candidate.evidence : []).flatMap((entry) => {
|
|
2427
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
2428
|
+
return [];
|
|
2429
|
+
const value = entry;
|
|
2430
|
+
const chapterId = typeof value.chapterId === "string" ? value.chapterId : "";
|
|
2431
|
+
const quote = typeof value.quote === "string" ? value.quote.trim() : "";
|
|
2432
|
+
const supports = typeof value.supports === "string" ? value.supports.trim() : "";
|
|
2433
|
+
if (!chapterId || !quote || quote.length > 80)
|
|
2434
|
+
return [];
|
|
2435
|
+
try {
|
|
2436
|
+
const chapter = this.store.getChapter(chapterId);
|
|
2437
|
+
if (chapter.workId !== workId || !this.quoteExists(String(chapter.content), quote))
|
|
2438
|
+
return [];
|
|
2439
|
+
return [{ chapterId, chapterTitle: chapter.title, quote, supports }];
|
|
2440
|
+
}
|
|
2441
|
+
catch {
|
|
2442
|
+
return [];
|
|
2443
|
+
}
|
|
2444
|
+
});
|
|
2445
|
+
if (evidence.length === 0) {
|
|
2446
|
+
skipped.push({ pair: pairKey, reason: "缺少有效原文证据" });
|
|
2447
|
+
continue;
|
|
2448
|
+
}
|
|
2449
|
+
const reason = typeof candidate.reason === "string" ? candidate.reason.trim() : "AI 发现角色身份可能重复";
|
|
2450
|
+
const contradictions = (Array.isArray(candidate.contradictions) ? candidate.contradictions : [])
|
|
2451
|
+
.filter((value) => typeof value === "string" && Boolean(value.trim()))
|
|
2452
|
+
.map((value) => value.trim())
|
|
2453
|
+
.slice(0, 12);
|
|
2454
|
+
const review = this.store.createReviewItem(workId, {
|
|
2455
|
+
itemType: "character-duplicate",
|
|
2456
|
+
severity: verdict === "same" && confidence >= 0.85 ? "high" : "medium",
|
|
2457
|
+
title: `疑似重复角色:${String(left.name)} / ${String(right.name)}`,
|
|
2458
|
+
description: [reason, contradictions.length ? `反证或疑点:${contradictions.join(";")}` : ""].filter(Boolean).join("\n"),
|
|
2459
|
+
entityRefs: [left, right].map((character) => ({ type: "character", id: character.id, versionNo: character.versionNo })),
|
|
2460
|
+
evidence,
|
|
2461
|
+
suggestion: `${verdict === "same" ? "AI 倾向同一角色" : "AI 无法完全确认"},置信度 ${Math.round(confidence * 100)}%;请由作者决定是否合并。`,
|
|
2462
|
+
status: "pending"
|
|
2463
|
+
});
|
|
2464
|
+
reviewIds.push(String(review.id));
|
|
2465
|
+
}
|
|
2466
|
+
return {
|
|
2467
|
+
characterCount: characters.length,
|
|
2468
|
+
candidateCount: extracted.length,
|
|
2469
|
+
reviewIds,
|
|
2470
|
+
reviewCount: reviewIds.length,
|
|
2471
|
+
skipped,
|
|
2472
|
+
callId: generated.callId,
|
|
2473
|
+
toolCallCount: generated.toolCalls.length
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2268
2476
|
async runCharacterExtraction(workId, scope, modelId, taskId) {
|
|
2269
2477
|
const chapters = this.getScopeChapters(workId, scope);
|
|
2270
2478
|
if (chapters.length === 0)
|