Skip to content

Commit 5b83a4b

Browse files
committed
修复:发布开书资源选择兼容修复
2 parents e3545c1 + 6355315 commit 5b83a4b

9 files changed

Lines changed: 225 additions & 30 deletions

‎README.md‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -161,16 +161,11 @@ Open-source AI novel writing assistant and long-form production studio.
161161

162162
## 最新更新
163163

164-
### 2026-09-16
165-
166-
#### 优化
167-
168-
- README 和公开文档站新增自动导演交互架构图,可从静态预览进入完整交互视图,查看写法引擎、反 AI 规则、章节生成和写后同步之间的关系。
169-
- Windows 桌面版更新至 `0.4.25`,统一展示 Biz Novel Studio 品牌,并在发布前校验安装包内的数据库升级内容,降低旧作品升级后进入素材库或视觉资产页面失败的风险。
164+
### 2026-09-17
170165

171166
#### 修复
172167

173-
- 修复 Windows 桌面版升级后打开素材库或视觉资产页面可能报错的问题;应用启动时会自动补齐漫画角色资产、场景资料和短剧角色图片所需的数据结构,保留已有项目与角色内容,无需删除数据库或手工迁移。
168+
- Windows 桌面版更新至 `0.4.26`。创建小说项目时,AI 把题材或推进方式的候选序号返回成资源标识也能安全识别;未知或越界选项仍会要求重新生成,减少“非法 ID”导致的开书中断。
174169

175170
完整历史更新见 [docs/releases/release-notes.md](./docs/releases/release-notes.md)。
176171

‎desktop/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ai-novel/desktop",
3-
"version": "0.4.25",
3+
"version": "0.4.26",
44
"private": true,
55
"license": "AGPL-3.0-only",
66
"author": "Biz Studio",

‎docs/releases/release-notes.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
## 更新历史
66

7+
### 2026-09-17
8+
9+
#### 修复
10+
11+
- Windows 桌面版更新至 `0.4.26`。创建小说项目时,AI 把题材或推进方式的候选序号返回成资源标识也能安全识别;未知或越界选项仍会要求重新生成,减少“非法 ID”导致的开书中断。
12+
713
### 2026-09-16
814

915
#### 优化

‎docs/wiki/prompts/prompt-registry-and-structured-output.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
- 抽取类 schema 如果用字符串承载“可读状态值”,必须在 PromptAsset 中明示数值也要按字符串输出,并在 schema 层对已经结构化的数值 / 布尔标量做确定性字符串化。典型场景是时间线 `stateChanges.before/after`:差评值、评分、倒计时等是剧情状态,不是计算字段,进入连续性账本时应保存为 `"19"`、`"5"` 这类可读文本,避免每次抽取都把合理数值输出推给 JSON repair。
3535
- 聚合型结构化 prompt 必须列出所有受限 enum 字段,不能只列最容易出错的字段。章节资产抽取这类一次性输出多个子账本的 prompt,应同时约束 `updateType`、`resourceType`、`narrativeFunction`、`scopeType`、`syncPlan` 等字段;否则模型会用语义合理但不被 schema 接受的自然分类词,导致后台任务被 Zod 校验失败卡住。
3636
- 结构化输出后的确定性归一只用于字段别名、枚举别名和兼容旧形状,例如把 `pacing` 映射为接收闸门的 `plot`、把 payoff `active` 映射为 `pending_payoff`、把字符串风险转成 `{ code, severity, summary }` 对象。不能用这种归一替代 AI 对剧情事实、风险等级或下一步动作的判断。
37+
- 当结构化 Prompt 要求 AI 从候选目录中选择稳定 ID 时,目录必须明确区分“候选序号”和“真实 ID”,并要求原样返回真实 ID。若模型仍把纯整数候选序号写入 ID 字段,schema 应先把 JSON 数字确定性转成字符串,`postValidate` 再按本次已传入的有序候选数组做确定性映射;精确 ID 必须优先,零、负数、非规范整数、越界序号和任意未知值必须继续拒绝并进入 semantic retry。该映射只修复已经结构化的目录引用,不能根据名称、关键词或描述猜测选择结果。
3738
- 章节接收闸门、时间线抽取和章节资产抽取都属于高频后台结构化 prompt,示例必须覆盖非空对象数组。`missingObligations`、`hooks/possibleHooks`、资源变化等字段不能只给空数组示例,否则模型在发现真实问题时容易自造字段或把对象压成字符串。
3839
- 事实抽取类 prompt 不继承创作温度。时间线、章节资产 delta、接收闸门等用于审校或账本写入的调用应在 service 层钳制低温,避免自动导演高创造温度放大 schema drift。
3940
- JSON repair 日志应保留 `promptId`、`schemaPaths`、`repairAttempt` 和 `validationError`。诊断 repair 率时先按 `promptId + schemaPath` 聚合,判断是 prompt 示例、枚举合同、上下文污染还是模型路由问题。

‎server/src/prompting/prompts/novel/resourceRecommendation.promptSchemas.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
import { z } from "zod";
22

3+
function normalizeResourceSelectionId(value: unknown): unknown {
4+
return typeof value === "number" && Number.isFinite(value) ? String(value) : value;
5+
}
6+
7+
const requiredResourceSelectionIdSchema = z.preprocess(
8+
normalizeResourceSelectionId,
9+
z.string().trim().min(1),
10+
);
11+
12+
const optionalResourceSelectionIdSchema = z.preprocess(
13+
normalizeResourceSelectionId,
14+
z.string().trim().optional().nullable(),
15+
);
16+
317
export const novelCreateResourceRecommendationSchema = z.object({
418
summary: z.string().trim().min(1),
5-
genreId: z.string().trim().min(1),
19+
genreId: requiredResourceSelectionIdSchema,
620
genreReason: z.string().trim().min(1),
7-
primaryStoryModeId: z.string().trim().min(1),
21+
primaryStoryModeId: requiredResourceSelectionIdSchema,
822
primaryStoryModeReason: z.string().trim().min(1),
9-
secondaryStoryModeId: z.string().trim().optional().nullable(),
23+
secondaryStoryModeId: optionalResourceSelectionIdSchema,
1024
secondaryStoryModeReason: z.string().trim().optional().nullable(),
1125
powerSystemMode: z.enum(["none", "soft", "ranked"]),
1226
powerSystemReason: z.string().trim().min(1),

‎server/src/prompting/prompts/novel/resourceRecommendation.prompts.ts‎

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,29 @@ export interface NovelCreateResourceRecommendationPromptInput {
1212
allowedStoryModeIds: string[];
1313
}
1414

15+
function resolveCatalogSelectionId(value: string, allowedIds: string[]): string | null {
16+
const normalized = value.trim();
17+
if (allowedIds.includes(normalized)) {
18+
return normalized;
19+
}
20+
21+
const ordinal = Number(normalized);
22+
if (!Number.isSafeInteger(ordinal) || ordinal < 1 || String(ordinal) !== normalized) {
23+
return null;
24+
}
25+
return allowedIds[ordinal - 1] ?? null;
26+
}
27+
28+
function formatAllowedIds(ids: string[]): string {
29+
return ids.join("、");
30+
}
31+
1532
export const novelCreateResourceRecommendationPrompt: PromptAsset<
1633
NovelCreateResourceRecommendationPromptInput,
1734
z.infer<typeof novelCreateResourceRecommendationSchema>
1835
> = {
1936
id: "novel.create.resource_recommendation",
20-
version: "v2",
37+
version: "v3",
2138
taskType: "planner",
2239
mode: "structured",
2340
language: "zh",
@@ -34,6 +51,7 @@ export const novelCreateResourceRecommendationPrompt: PromptAsset<
3451
"你的任务是根据用户当前提供的开书信息,从给定的题材基底库和推进模式库中,推荐一套最适合作为默认起步底座的组合。",
3552
"",
3653
"只允许从给定列表中选择,不得杜撰新的题材 ID、推进模式 ID、名称或路径。",
54+
"每个候选项同时提供候选序号和 ID。候选序号只用于定位;输出时必须复制“ID(选择后必须原样返回)”后面的完整字符串,绝对不能把 1、2、3 等候选序号写进任何 ID 字段。",
3755
"",
3856
"推荐时必须优先考虑:",
3957
"1. 是否能帮助新手低认知负担地开始第一本书",
@@ -65,9 +83,9 @@ export const novelCreateResourceRecommendationPrompt: PromptAsset<
6583
"6. powerSystemMode / powerSystemReason:给出是否需要战力体系及原因;none 是正常完整结果。",
6684
"",
6785
"硬性约束:",
68-
"1. genreId 必须来自给定题材基底列表。",
69-
"2. primaryStoryModeId 必须来自给定推进模式列表。",
70-
"3. secondaryStoryModeId 如果有值,必须来自给定推进模式列表,且不能与 primaryStoryModeId 相同。",
86+
"1. genreId 必须原样复制给定题材基底列表中的完整 ID,不能返回候选序号。",
87+
"2. primaryStoryModeId 必须原样复制给定推进模式列表中的完整 ID,不能返回候选序号。",
88+
"3. secondaryStoryModeId 如果有值,必须原样复制给定推进模式列表中的完整 ID,不能返回候选序号,且不能与 primaryStoryModeId 相同。",
7189
"4. 不得返回空 summary、空 genreReason 或空 primaryStoryModeReason。",
7290
].join("\n")),
7391
new HumanMessage([
@@ -83,30 +101,53 @@ export const novelCreateResourceRecommendationPrompt: PromptAsset<
83101
].join("\n")),
84102
],
85103
postValidate: (output, input) => {
86-
const allowedGenreIds = new Set(input.allowedGenreIds);
87-
const allowedStoryModeIds = new Set(input.allowedStoryModeIds);
104+
const genreId = resolveCatalogSelectionId(output.genreId, input.allowedGenreIds);
105+
const primaryStoryModeId = resolveCatalogSelectionId(
106+
output.primaryStoryModeId,
107+
input.allowedStoryModeIds,
108+
);
88109

89-
if (!allowedGenreIds.has(output.genreId)) {
90-
throw new Error(`题材推荐结果包含非法 ID:${output.genreId}`);
110+
if (!genreId) {
111+
throw new Error([
112+
`题材推荐结果包含非法 ID:${output.genreId}。`,
113+
"请复制候选项中的完整 ID,不要返回候选序号。",
114+
`可选 ID:${formatAllowedIds(input.allowedGenreIds)}`,
115+
].join(" "));
91116
}
92117

93-
if (!allowedStoryModeIds.has(output.primaryStoryModeId)) {
94-
throw new Error(`主推进模式推荐结果包含非法 ID:${output.primaryStoryModeId}`);
118+
if (!primaryStoryModeId) {
119+
throw new Error([
120+
`主推进模式推荐结果包含非法 ID:${output.primaryStoryModeId}。`,
121+
"请复制候选项中的完整 ID,不要返回候选序号。",
122+
`可选 ID:${formatAllowedIds(input.allowedStoryModeIds)}`,
123+
].join(" "));
95124
}
96125

97-
const secondaryId = output.secondaryStoryModeId?.trim() ?? "";
98-
if (secondaryId) {
99-
if (!allowedStoryModeIds.has(secondaryId)) {
100-
throw new Error(`副推进模式推荐结果包含非法 ID:${secondaryId}`);
126+
const secondaryRawId = output.secondaryStoryModeId?.trim() ?? "";
127+
const secondaryStoryModeId = secondaryRawId
128+
? resolveCatalogSelectionId(secondaryRawId, input.allowedStoryModeIds)
129+
: null;
130+
if (secondaryRawId) {
131+
if (!secondaryStoryModeId) {
132+
throw new Error([
133+
`副推进模式推荐结果包含非法 ID:${secondaryRawId}。`,
134+
"请复制候选项中的完整 ID,不要返回候选序号。",
135+
`可选 ID:${formatAllowedIds(input.allowedStoryModeIds)}`,
136+
].join(" "));
101137
}
102-
if (secondaryId === output.primaryStoryModeId) {
138+
if (secondaryStoryModeId === primaryStoryModeId) {
103139
throw new Error("副推进模式不能与主推进模式相同。");
104140
}
105141
if (!(output.secondaryStoryModeReason?.trim())) {
106142
throw new Error("存在副推进模式时,必须返回对应的推荐理由。");
107143
}
108144
}
109145

110-
return output;
146+
return {
147+
...output,
148+
genreId,
149+
primaryStoryModeId,
150+
secondaryStoryModeId,
151+
};
111152
},
112153
};

‎server/src/prompting/registry/promptAssetLoaderEntries.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ export const promptAssetLoaderEntries: PromptAssetLoaderEntry[] = [
466466
load: () => require("../prompts/novel/production.prompts").novelProductionCharactersPrompt as UnknownPromptAsset,
467467
},
468468
{
469-
key: "novel.create.resource_recommendation@v1",
469+
key: "novel.create.resource_recommendation@v3",
470470
load: () => require("../prompts/novel/resourceRecommendation.prompts").novelCreateResourceRecommendationPrompt as UnknownPromptAsset,
471471
},
472472
{

‎server/src/services/novel/NovelCreateResourceRecommendationService.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ function flattenStoryModeOptions(nodes: StoryModeTreeNode[], path: string[] = []
119119

120120
function buildGenreCatalogText(options: FlattenedGenreOption[]): string {
121121
return options.map((option, index) => [
122-
`${index + 1}. ID=${option.id}`,
122+
`候选序号(仅用于定位):${index + 1}`,
123+
`ID(选择后必须原样返回):${option.id}`,
123124
`路径:${option.path}`,
124125
option.description ? `说明:${option.description}` : "",
125126
option.template ? `使用倾向:${option.template}` : "",
@@ -128,7 +129,8 @@ function buildGenreCatalogText(options: FlattenedGenreOption[]): string {
128129

129130
function buildStoryModeCatalogText(options: FlattenedStoryModeOption[]): string {
130131
return options.map((option, index) => [
131-
`${index + 1}. ID=${option.id}`,
132+
`候选序号(仅用于定位):${index + 1}`,
133+
`ID(选择后必须原样返回):${option.id}`,
132134
`路径:${option.path}`,
133135
option.description ? `说明:${option.description}` : "",
134136
`核心驱动:${option.profile.coreDrive}`,

‎server/tests/novelCreateResourceSelectionContracts.test.js‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,34 @@ const test = require("node:test");
66
const root = path.resolve(__dirname, "..");
77
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
88

9+
function recommendationOutput(overrides = {}) {
10+
return {
11+
summary: "适合作为开书底座。",
12+
genreId: "genre-a",
13+
genreReason: "题材匹配。",
14+
primaryStoryModeId: "mode-a",
15+
primaryStoryModeReason: "推进稳定。",
16+
secondaryStoryModeId: null,
17+
secondaryStoryModeReason: null,
18+
powerSystemMode: "none",
19+
powerSystemReason: "无需等级体系。",
20+
caution: null,
21+
...overrides,
22+
};
23+
}
24+
25+
function recommendationInput(overrides = {}) {
26+
return {
27+
userIntentSummary: "测试开书",
28+
powerSystemPreference: "ai_recommend",
29+
genreCatalogText: "",
30+
storyModeCatalogText: "",
31+
allowedGenreIds: ["genre-a", "genre-b"],
32+
allowedStoryModeIds: ["mode-a", "mode-b", "mode-c"],
33+
...overrides,
34+
};
35+
}
36+
937
test("resource recommendation records user and AI selection sources", () => {
1038
const shared = read("../shared/types/novelResourceRecommendation.ts");
1139
const service = read("src/services/novel/NovelCreateResourceRecommendationService.ts");
@@ -23,6 +51,114 @@ test("AI secondary mode cannot duplicate the resolved primary mode", () => {
2351
assert.match(service, /item\.id !== primary\?\.id/);
2452
});
2553

54+
test("resource recommendation resolves structured catalog ordinals without accepting arbitrary ids", () => {
55+
const { novelCreateResourceRecommendationPrompt } = require(
56+
"../dist/prompting/prompts/novel/resourceRecommendation.prompts.js"
57+
);
58+
const validate = novelCreateResourceRecommendationPrompt.postValidate;
59+
assert.equal(typeof validate, "function");
60+
61+
const resolved = validate(
62+
recommendationOutput({
63+
genreId: "2",
64+
primaryStoryModeId: "3",
65+
secondaryStoryModeId: "1",
66+
secondaryStoryModeReason: "补充关系推进。",
67+
}),
68+
recommendationInput(),
69+
{},
70+
);
71+
assert.equal(resolved.genreId, "genre-b");
72+
assert.equal(resolved.primaryStoryModeId, "mode-c");
73+
assert.equal(resolved.secondaryStoryModeId, "mode-a");
74+
75+
assert.throws(
76+
() => validate(recommendationOutput({ genreId: "genre-missing" }), recommendationInput(), {}),
77+
/题材推荐结果包含非法 ID/,
78+
);
79+
assert.throws(
80+
() => validate(recommendationOutput({ genreId: "3" }), recommendationInput(), {}),
81+
/题材推荐结果包含非法 ID/,
82+
);
83+
for (const invalidOrdinal of ["0", "-1", "02", "2.0"]) {
84+
assert.throws(
85+
() => validate(recommendationOutput({ genreId: invalidOrdinal }), recommendationInput(), {}),
86+
/题材推荐结果包含非法 ID/,
87+
);
88+
}
89+
});
90+
91+
test("resource recommendation normalizes numeric catalog ordinals before semantic validation", () => {
92+
const { novelCreateResourceRecommendationSchema } = require(
93+
"../dist/prompting/prompts/novel/resourceRecommendation.promptSchemas.js"
94+
);
95+
const { novelCreateResourceRecommendationPrompt } = require(
96+
"../dist/prompting/prompts/novel/resourceRecommendation.prompts.js"
97+
);
98+
const parsed = novelCreateResourceRecommendationSchema.parse(recommendationOutput({
99+
genreId: 2,
100+
primaryStoryModeId: 3,
101+
secondaryStoryModeId: 1,
102+
secondaryStoryModeReason: "补充关系推进。",
103+
}));
104+
const resolved = novelCreateResourceRecommendationPrompt.postValidate(
105+
parsed,
106+
recommendationInput(),
107+
{},
108+
);
109+
110+
assert.equal(resolved.genreId, "genre-b");
111+
assert.equal(resolved.primaryStoryModeId, "mode-c");
112+
assert.equal(resolved.secondaryStoryModeId, "mode-a");
113+
assert.throws(
114+
() => novelCreateResourceRecommendationPrompt.postValidate(
115+
novelCreateResourceRecommendationSchema.parse(recommendationOutput({ genreId: 0 })),
116+
recommendationInput(),
117+
{},
118+
),
119+
/题材推荐结果包含非法 ID/,
120+
);
121+
});
122+
123+
test("resource recommendation prefers an exact numeric id and rejects duplicate normalized modes", () => {
124+
const { novelCreateResourceRecommendationPrompt } = require(
125+
"../dist/prompting/prompts/novel/resourceRecommendation.prompts.js"
126+
);
127+
const validate = novelCreateResourceRecommendationPrompt.postValidate;
128+
129+
const exact = validate(
130+
recommendationOutput({ genreId: "2" }),
131+
recommendationInput({ allowedGenreIds: ["2", "genre-b"] }),
132+
{},
133+
);
134+
assert.equal(exact.genreId, "2");
135+
136+
assert.throws(
137+
() => validate(
138+
recommendationOutput({
139+
primaryStoryModeId: "1",
140+
secondaryStoryModeId: "mode-a",
141+
secondaryStoryModeReason: "重复项。",
142+
}),
143+
recommendationInput(),
144+
{},
145+
),
146+
/副推进模式不能与主推进模式相同/,
147+
);
148+
});
149+
150+
test("resource recommendation prompt distinguishes candidate ordinals from stable ids", () => {
151+
const service = read("src/services/novel/NovelCreateResourceRecommendationService.ts");
152+
const prompt = read("src/prompting/prompts/novel/resourceRecommendation.prompts.ts");
153+
const loaders = read("src/prompting/registry/promptAssetLoaderEntries.ts");
154+
155+
assert.match(service, /候选序号(仅用于定位)/);
156+
assert.match(service, /ID(选择后必须原样返回)/);
157+
assert.match(prompt, /version: "v3"/);
158+
assert.match(prompt, /绝对不能把 1、2、3 等候选序号写进任何 ID 字段/);
159+
assert.match(loaders, /novel\.create\.resource_recommendation@v3/);
160+
});
161+
26162
test("candidate workflow persists the resolved production foundation for recovery", () => {
27163
const stage = read("src/services/novel/director/phases/novelDirectorCandidateStage.ts");
28164
const directorTypes = read("../shared/types/novelDirector.ts");

0 commit comments

Comments
 (0)