From d415c1d94bca7af435302b3da5bd5a7fc6890bd1 Mon Sep 17 00:00:00 2001 From: daiwenkai <741007187@qq.com> Date: Mon, 23 Mar 2026 20:59:19 +0400 Subject: [PATCH] Improve chapter length control and OpenAI token handling --- .../src/__tests__/pipeline-runner.test.ts | 48 ++++++++ .../__tests__/post-write-validator.test.ts | 15 +++ packages/core/src/__tests__/provider.test.ts | 107 ++++++++++++++++++ .../core/src/__tests__/writer-prompts.test.ts | 53 +++++++++ .../core/src/agents/en-prompt-sections.ts | 16 +-- .../core/src/agents/post-write-validator.ts | 41 ++++++- packages/core/src/agents/writer-prompts.ts | 27 +++-- packages/core/src/agents/writer.ts | 30 +++-- packages/core/src/llm/provider.ts | 99 +++++++++++++--- packages/core/src/pipeline/runner.ts | 40 ++++++- packages/core/src/utils/chapter-length.ts | 32 ++++++ 11 files changed, 455 insertions(+), 53 deletions(-) create mode 100644 packages/core/src/__tests__/provider.test.ts create mode 100644 packages/core/src/__tests__/writer-prompts.test.ts create mode 100644 packages/core/src/utils/chapter-length.ts diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index 91dd6c6e..5ecdee58 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -325,6 +325,54 @@ describe("PipelineRunner", () => { await rm(root, { recursive: true, force: true }); }); + it("fails the pipeline when final chapter length exceeds the requested override", async () => { + const { root, runner, bookId } = await createRunnerFixture(); + const oversizedContent = "长".repeat(1801); + + vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue( + createWriterOutput({ + content: oversizedContent, + wordCount: oversizedContent.length, + }), + ); + vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue( + createAuditResult({ + passed: true, + issues: [], + summary: "clean", + }), + ); + vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue( + createReviseOutput({ + revisedContent: oversizedContent, + wordCount: oversizedContent.length, + }), + ); + vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined); + vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined); + vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue( + createAnalyzedOutput({ + content: oversizedContent, + wordCount: oversizedContent.length, + }), + ); + + const result = await runner.writeNextChapter(bookId, 1500); + + expect(result.status).toBe("audit-failed"); + expect(result.auditResult.passed).toBe(false); + expect(result.auditResult.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + category: "章节长度", + severity: "critical", + }), + ]), + ); + + await rm(root, { recursive: true, force: true }); + }); + it("reports only resumed chapters in import results", async () => { const { root, runner, state, bookId } = await createRunnerFixture(); const now = "2026-03-19T00:00:00.000Z"; diff --git a/packages/core/src/__tests__/post-write-validator.test.ts b/packages/core/src/__tests__/post-write-validator.test.ts index 83359bdf..e450c0e8 100644 --- a/packages/core/src/__tests__/post-write-validator.test.ts +++ b/packages/core/src/__tests__/post-write-validator.test.ts @@ -63,6 +63,21 @@ describe("validatePostWrite", () => { expect(findRule(result, "高疲劳词")).toBeDefined(); }); + it("detects chapter content that exceeds the configured target range", () => { + const content = "长".repeat(1801); + const result = validatePostWrite(content, baseProfile, null, { targetWordCount: 1500 }); + const violation = findRule(result, "章节长度"); + expect(violation).toBeDefined(); + expect(violation!.severity).toBe("error"); + expect(violation!.description).toContain("允许上限1700字"); + }); + + it("allows chapter content within the configured target range", () => { + const content = "正".repeat(1680); + const result = validatePostWrite(content, baseProfile, null, { targetWordCount: 1500 }); + expect(findRule(result, "章节长度")).toBeUndefined(); + }); + it("detects meta-narration patterns", () => { const content = "故事发展到了这里,主角终于做出了选择。他站起来走向门口。"; const result = validatePostWrite(content, baseProfile, null); diff --git a/packages/core/src/__tests__/provider.test.ts b/packages/core/src/__tests__/provider.test.ts new file mode 100644 index 00000000..80f1c205 --- /dev/null +++ b/packages/core/src/__tests__/provider.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; +import { + chatCompletion, + chatWithTools, + type LLMClient, + type ToolDefinition, +} from "../llm/provider.js"; + +function createOpenAIClientMock(create: ReturnType, stream: boolean): LLMClient { + return { + provider: "openai", + apiFormat: "chat", + stream, + defaults: { + temperature: 0.7, + maxTokens: 256, + thinkingBudget: 0, + extra: {}, + }, + _openai: { + chat: { + completions: { + create, + }, + }, + } as unknown as LLMClient["_openai"], + }; +} + +function streamFrom(items: ReadonlyArray): AsyncIterable { + return { + async *[Symbol.asyncIterator](): AsyncIterator { + for (const item of items) { + yield item; + } + }, + }; +} + +describe("OpenAI chat token parameter compatibility", () => { + it("uses max_completion_tokens for GPT-5 chat completions", async () => { + const create = vi.fn().mockResolvedValue({ + choices: [{ message: { content: "OK" } }], + usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 }, + }); + const client = createOpenAIClientMock(create, false); + + await chatCompletion(client, "gpt-5.4-mini", [ + { role: "user", content: "Say OK" }, + ], { maxTokens: 16 }); + + const params = create.mock.calls[0]?.[0] as Record; + expect(params.max_completion_tokens).toBe(16); + expect(params).not.toHaveProperty("max_tokens"); + }); + + it("falls back to max_tokens when a proxy rejects max_completion_tokens", async () => { + const create = vi.fn() + .mockRejectedValueOnce({ + param: "max_completion_tokens", + message: "Unsupported parameter: 'max_completion_tokens'", + }) + .mockResolvedValueOnce({ + choices: [{ message: { content: "OK" } }], + usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 }, + }); + const client = createOpenAIClientMock(create, false); + + await chatCompletion(client, "gpt-5.4-mini", [ + { role: "user", content: "Say OK" }, + ], { maxTokens: 16 }); + + const firstParams = create.mock.calls[0]?.[0] as Record; + const secondParams = create.mock.calls[1]?.[0] as Record; + expect(firstParams.max_completion_tokens).toBe(16); + expect(secondParams.max_tokens).toBe(16); + }); + + it("uses max_completion_tokens for GPT-5 tool-calling chat requests", async () => { + const create = vi.fn().mockResolvedValue(streamFrom([ + { choices: [{ delta: { content: "OK" } }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ])); + const client = createOpenAIClientMock(create, true); + const tools: ToolDefinition[] = [ + { + name: "lookup", + description: "Lookup something", + parameters: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, + ]; + + await chatWithTools(client, "gpt-5.4-mini", [ + { role: "user", content: "Find something" }, + ], tools, { temperature: 0.2, maxTokens: 24 }); + + const params = create.mock.calls[0]?.[0] as Record; + expect(params.max_completion_tokens).toBe(24); + expect(params).not.toHaveProperty("max_tokens"); + }); +}); diff --git a/packages/core/src/__tests__/writer-prompts.test.ts b/packages/core/src/__tests__/writer-prompts.test.ts new file mode 100644 index 00000000..afc24e36 --- /dev/null +++ b/packages/core/src/__tests__/writer-prompts.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { buildWriterSystemPrompt } from "../agents/writer-prompts.js"; +import type { BookConfig } from "../models/book.js"; +import type { GenreProfile } from "../models/genre-profile.js"; + +const book: BookConfig = { + id: "prompt-book", + title: "Prompt Book", + platform: "tomato", + genre: "xuanhuan", + status: "active", + targetChapters: 100, + chapterWordCount: 3000, + createdAt: "2026-03-19T00:00:00.000Z", + updatedAt: "2026-03-19T00:00:00.000Z", +}; + +const genreProfile: GenreProfile = { + id: "xuanhuan", + name: "玄幻", + language: "zh", + chapterTypes: ["推进", "冲突"], + fatigueWords: [], + pacingRule: "控制推进速度。", + numericalSystem: false, + powerScaling: false, + eraResearch: false, + auditDimensions: [], + satisfactionTypes: [], +}; + +describe("buildWriterSystemPrompt", () => { + it("uses the override target instead of the book default chapter length", () => { + const prompt = buildWriterSystemPrompt( + book, + genreProfile, + null, + "", + "", + "", + undefined, + 1, + "creative", + undefined, + "zh", + 1500, + ); + + expect(prompt).toContain("目标1500字,允许区间1300-1700字"); + expect(prompt).not.toContain("每章3000字左右"); + expect(prompt).not.toContain("正文内容,3000字左右"); + }); +}); diff --git a/packages/core/src/agents/en-prompt-sections.ts b/packages/core/src/agents/en-prompt-sections.ts index c457d766..a2701378 100644 --- a/packages/core/src/agents/en-prompt-sections.ts +++ b/packages/core/src/agents/en-prompt-sections.ts @@ -1,9 +1,10 @@ import type { BookConfig } from "../models/book.js"; import type { GenreProfile } from "../models/genre-profile.js"; -import type { BookRules } from "../models/book-rules.js"; +import type { ChapterLengthTarget } from "../utils/chapter-length.js"; +import { formatChapterLengthTarget } from "../utils/chapter-length.js"; // English equivalent of buildCoreRules() — universal writing rules for English fiction -export function buildEnglishCoreRules(_book: BookConfig): string { +export function buildEnglishCoreRules(target: ChapterLengthTarget): string { return `## Universal Writing Rules ### Character Rules @@ -31,7 +32,8 @@ export function buildEnglishCoreRules(_book: BookConfig): string { 16. **Promise and payoff**: Every planted hook must be resolved. Every mystery must have an answer. 17. **Escalation**: Each conflict should feel higher-stakes than the last—either externally or emotionally. 18. **Reader proxy**: One character should react with surprise/excitement/fear when remarkable things happen, giving readers permission to feel the same. -19. **Pacing breathing room**: After a high-intensity sequence, give 0.5-1 chapter of lower intensity before the next escalation.`; +19. **Pacing breathing room**: After a high-intensity sequence, give 0.5-1 chapter of lower intensity before the next escalation. +20. **Length discipline**: Keep each chapter at ${formatChapterLengthTarget(target, "en")}.`; } // English equivalent of buildAntiAIExamples() @@ -91,7 +93,7 @@ This method is for YOUR planning. The terms never appear in the chapter text.`; } // English pre-write checklist -export function buildEnglishPreWriteChecklist(book: BookConfig, gp: GenreProfile): string { +export function buildEnglishPreWriteChecklist(gp: GenreProfile, target: ChapterLengthTarget): string { const items = [ "Outline anchor: Which volume_outline plot point does this chapter advance?", "POV: Whose perspective? Consistent throughout?", @@ -99,7 +101,7 @@ export function buildEnglishPreWriteChecklist(book: BookConfig, gp: GenreProfile "Sensory grounding: At least 2 non-visual senses per major scene", "Character consistency: Does every character act from their established motivation?", "Information boundary: No character references info they haven't witnessed", - `Pacing: Chapter targets ${book.chapterWordCount} words. ${gp.pacingRule}`, + `Pacing: Chapter stays at ${formatChapterLengthTarget(target, "en")}. ${gp.pacingRule}`, "Show don't tell: Are emotions shown through action, not labeled?", "AI-tell check: No banned analytical language in prose?", "Conflict: What is the core tension driving this chapter?", @@ -119,10 +121,10 @@ ${items.map((item, i) => `${i + 1}. ${item}`).join("\n")}`; } // English genre intro -export function buildEnglishGenreIntro(book: BookConfig, gp: GenreProfile): string { +export function buildEnglishGenreIntro(book: BookConfig, gp: GenreProfile, target: ChapterLengthTarget): string { return `You are a professional ${gp.name} web fiction author writing for English-speaking platforms (Royal Road, Kindle Unlimited, Scribble Hub). -Target: ${book.chapterWordCount} words per chapter, ${book.targetChapters} total chapters. +Target: ${formatChapterLengthTarget(target, "en")}, ${book.targetChapters} total chapters. Write in English. Vary sentence length. Mix short punchy sentences with longer flowing ones. Maintain consistent narrative voice throughout.`; } diff --git a/packages/core/src/agents/post-write-validator.ts b/packages/core/src/agents/post-write-validator.ts index df7379b2..8e5e5832 100644 --- a/packages/core/src/agents/post-write-validator.ts +++ b/packages/core/src/agents/post-write-validator.ts @@ -7,6 +7,7 @@ import type { BookRules } from "../models/book-rules.js"; import type { GenreProfile } from "../models/genre-profile.js"; +import { getChapterLengthTarget } from "../utils/chapter-length.js"; export interface PostWriteViolation { readonly rule: string; @@ -15,6 +16,10 @@ export interface PostWriteViolation { readonly suggestion: string; } +export interface PostWriteValidationOptions { + readonly targetWordCount?: number; +} + // --- Marker word lists --- /** AI转折/惊讶标记词 */ @@ -52,14 +57,19 @@ export function validatePostWrite( content: string, genreProfile: GenreProfile, bookRules: BookRules | null, + options: PostWriteValidationOptions = {}, ): ReadonlyArray { const violations: PostWriteViolation[] = []; + if (options.targetWordCount !== undefined) { + violations.push(...validateChapterLength(content, options.targetWordCount)); + } + // Skip Chinese-specific rules for English content const isEnglish = genreProfile.language === "en"; if (isEnglish) { // For English, only run book-specific prohibitions and paragraph length check - return validatePostWriteEnglish(content, genreProfile, bookRules); + return [...violations, ...validatePostWriteEnglish(content, genreProfile, bookRules)]; } // 1. 硬性禁令: "不是…而是…" 句式 @@ -244,6 +254,35 @@ export function validatePostWrite( return violations; } +function validateChapterLength(content: string, targetWordCount: number): ReadonlyArray { + const lengthTarget = getChapterLengthTarget(targetWordCount); + const actualLength = content.length; + + if (actualLength < lengthTarget.min) { + return [ + { + rule: "章节长度", + severity: "error", + description: `正文长度${actualLength}字,低于目标${lengthTarget.target}字的允许下限${lengthTarget.min}字`, + suggestion: `补足必要的剧情推进、冲突兑现或章末钩子,将篇幅补到${lengthTarget.min}-${lengthTarget.max}字区间`, + }, + ]; + } + + if (actualLength > lengthTarget.max) { + return [ + { + rule: "章节长度", + severity: "error", + description: `正文长度${actualLength}字,超出目标${lengthTarget.target}字的允许上限${lengthTarget.max}字`, + suggestion: `压缩重复描写、解释性句子和过渡段,把篇幅收束到${lengthTarget.min}-${lengthTarget.max}字区间`, + }, + ]; + } + + return []; +} + /** English-specific post-write validation rules. */ function validatePostWriteEnglish( content: string, diff --git a/packages/core/src/agents/writer-prompts.ts b/packages/core/src/agents/writer-prompts.ts index 3659a191..4f5d31d3 100644 --- a/packages/core/src/agents/writer-prompts.ts +++ b/packages/core/src/agents/writer-prompts.ts @@ -3,6 +3,7 @@ import type { GenreProfile } from "../models/genre-profile.js"; import type { BookRules } from "../models/book-rules.js"; import { buildFanficCanonSection, buildCharacterVoiceProfiles, buildFanficModeInstructions } from "./fanfic-prompt-sections.js"; import { buildEnglishCoreRules, buildEnglishAntiAIRules, buildEnglishCharacterMethod, buildEnglishPreWriteChecklist, buildEnglishGenreIntro } from "./en-prompt-sections.js"; +import { getChapterLengthTarget, formatChapterLengthTarget, type ChapterLengthTarget } from "../utils/chapter-length.js"; export interface FanficContext { readonly fanficCanon: string; @@ -26,17 +27,19 @@ export function buildWriterSystemPrompt( mode: "full" | "creative" = "full", fanficContext?: FanficContext, languageOverride?: "zh" | "en", + targetWordCount?: number, ): string { const isEnglish = (languageOverride ?? genreProfile.language) === "en"; + const chapterLengthTarget = getChapterLengthTarget(targetWordCount ?? book.chapterWordCount); const outputSection = mode === "creative" - ? buildCreativeOutputFormat(book, genreProfile) - : buildOutputFormat(book, genreProfile); + ? buildCreativeOutputFormat(book, genreProfile, chapterLengthTarget) + : buildOutputFormat(book, genreProfile, chapterLengthTarget); const sections = isEnglish ? [ - buildEnglishGenreIntro(book, genreProfile), - buildEnglishCoreRules(book), + buildEnglishGenreIntro(book, genreProfile, chapterLengthTarget), + buildEnglishCoreRules(chapterLengthTarget), buildEnglishAntiAIRules(), buildEnglishCharacterMethod(), buildGenreRules(genreProfile, genreBody), @@ -47,12 +50,12 @@ export function buildWriterSystemPrompt( fanficContext ? buildFanficCanonSection(fanficContext.fanficCanon, fanficContext.fanficMode) : "", fanficContext ? buildCharacterVoiceProfiles(fanficContext.fanficCanon) : "", fanficContext ? buildFanficModeInstructions(fanficContext.fanficMode, fanficContext.allowedDeviations) : "", - buildEnglishPreWriteChecklist(book, genreProfile), + buildEnglishPreWriteChecklist(genreProfile, chapterLengthTarget), outputSection, ] : [ buildGenreIntro(book, genreProfile), - buildCoreRules(book), + buildCoreRules(chapterLengthTarget), buildAntiAIExamples(), buildCharacterPsychologyMethod(), buildSupportingCharacterMethod(), @@ -88,11 +91,11 @@ function buildGenreIntro(book: BookConfig, gp: GenreProfile): string { // Core rules (~25 universal rules) // --------------------------------------------------------------------------- -function buildCoreRules(book: BookConfig): string { +function buildCoreRules(target: ChapterLengthTarget): string { return `## 核心规则 1. 以简体中文工作,句子长短交替,段落适合手机阅读(3-5行/段) -2. 每章${book.chapterWordCount}字左右 +2. 每章${formatChapterLengthTarget(target, "zh")} 3. 伏笔前后呼应,不留悬空线;所有埋下的伏笔都必须在后续收回 4. 只读必要上下文,不机械重复已有内容 @@ -462,7 +465,7 @@ function buildPreWriteChecklist(book: BookConfig, gp: GenreProfile): string { // Creative-only output format (no settlement blocks) // --------------------------------------------------------------------------- -function buildCreativeOutputFormat(book: BookConfig, gp: GenreProfile): string { +function buildCreativeOutputFormat(book: BookConfig, gp: GenreProfile, target: ChapterLengthTarget): string { const resourceRow = gp.numericalSystem ? "| 当前资源总量 | X | 与账本一致 |\n| 本章预计增量 | +X(来源) | 无增量写+0 |" : ""; @@ -487,7 +490,7 @@ ${preWriteTable} (章节标题,不含"第X章") === CHAPTER_CONTENT === -(正文内容,${book.chapterWordCount}字左右) +(正文内容,${formatChapterLengthTarget(target, "zh")}) 【重要】本次只需输出以上三个区块(PRE_WRITE_CHECK、CHAPTER_TITLE、CHAPTER_CONTENT)。 状态卡、伏笔池、摘要等追踪文件将由后续结算阶段处理,请勿输出。`; @@ -497,7 +500,7 @@ ${preWriteTable} // Output format // --------------------------------------------------------------------------- -function buildOutputFormat(book: BookConfig, gp: GenreProfile): string { +function buildOutputFormat(book: BookConfig, gp: GenreProfile, target: ChapterLengthTarget): string { const resourceRow = gp.numericalSystem ? "| 当前资源总量 | X | 与账本一致 |\n| 本章预计增量 | +X(来源) | 无增量写+0 |" : ""; @@ -540,7 +543,7 @@ ${preWriteTable} (章节标题,不含"第X章") === CHAPTER_CONTENT === -(正文内容,${book.chapterWordCount}字左右) +(正文内容,${formatChapterLengthTarget(target, "zh")}) ${postSettlement} diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index 325aee6d..63e87b7b 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -12,6 +12,7 @@ import { analyzeAITells } from "./ai-tells.js"; import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js"; import { extractPOVFromOutline, filterMatrixByPOV, filterHooksByPOV } from "../utils/pov-filter.js"; import { parseCreativeOutput } from "./writer-parser.js"; +import { getChapterLengthTarget } from "../utils/chapter-length.js"; import { readFile, writeFile, mkdir, readdir } from "node:fs/promises"; import { join } from "node:path"; @@ -107,9 +108,10 @@ export class WriterAgent extends BaseAgent { // ── Phase 1: Creative writing (temperature 0.7) ── const resolvedLanguage = book.language ?? genreProfile.language; + const targetWordCount = input.wordCountOverride ?? book.chapterWordCount; const creativeSystemPrompt = buildWriterSystemPrompt( book, genreProfile, bookRules, bookRulesBody, genreBody, styleGuide, styleFingerprint, - chapterNumber, "creative", fanficContext, resolvedLanguage, + chapterNumber, "creative", fanficContext, resolvedLanguage, targetWordCount, ); // Smart context filtering: inject only relevant parts of truth files @@ -136,7 +138,7 @@ export class WriterAgent extends BaseAgent { ledger: genreProfile.numericalSystem ? ledger : "", hooks: povFilteredHooks, recentChapters, - wordCount: input.wordCountOverride ?? book.chapterWordCount, + wordCount: targetWordCount, externalContext: input.externalContext, chapterSummaries: filteredSummaries, subplotBoard: filteredSubplots, @@ -152,9 +154,8 @@ export class WriterAgent extends BaseAgent { this.ctx.logger?.info(`Phase 1: creative writing for chapter ${chapterNumber}`); - // Scale maxTokens to chapter word count (Chinese ≈ 1.5 tokens/char) - const targetWords = input.wordCountOverride ?? book.chapterWordCount; - const creativeMaxTokens = Math.max(8192, Math.ceil(targetWords * 2)); + // Completion budget scales with the requested chapter target instead of always leaving a huge headroom. + const creativeMaxTokens = Math.max(4096, Math.ceil(targetWordCount * 2.2)); const creativeResponse = await this.chat( [ @@ -190,7 +191,9 @@ export class WriterAgent extends BaseAgent { const settleUsage = settleResult.usage; // ── Post-write validation (regex + rule-based, zero LLM cost) ── - const ruleViolations = validatePostWrite(creative.content, genreProfile, bookRules); + const ruleViolations = validatePostWrite(creative.content, genreProfile, bookRules, { + targetWordCount, + }); const aiTellIssues = analyzeAITells(creative.content).issues; const postWriteErrors = ruleViolations.filter(v => v.severity === "error"); @@ -365,6 +368,7 @@ export class WriterAgent extends BaseAgent { readonly parentCanon?: string; readonly language?: "zh" | "en"; }): string { + const chapterLengthTarget = getChapterLengthTarget(params.wordCount); const contextBlock = params.externalContext ? `\n## 外部指令\n以下是来自外部系统的创作指令,请在本章中融入:\n\n${params.externalContext}\n` : ""; @@ -428,9 +432,10 @@ ${params.volumeOutline} - PRE_WRITE_CHECK must identify which outline node this chapter covers. Requirements: -- Chapter body must be at least ${params.wordCount} words -- Output PRE_WRITE_CHECK first, then the chapter -- Output only PRE_WRITE_CHECK, CHAPTER_TITLE, and CHAPTER_CONTENT blocks`; + - Target ${params.wordCount} words. Keep the chapter body within ${chapterLengthTarget.min}-${chapterLengthTarget.max} words. + - If the draft runs long, compress repetition, exposition, and filler beats instead of adding new scenes. + - Output PRE_WRITE_CHECK first, then the chapter + - Output only PRE_WRITE_CHECK, CHAPTER_TITLE, and CHAPTER_CONTENT blocks`; } return `请续写第${params.chapterNumber}章。 @@ -457,9 +462,10 @@ ${params.volumeOutline} - PRE_WRITE_CHECK中必须明确标注本章对应的卷纲节点 要求: -- 正文不少于${params.wordCount}字 -- 先输出写作自检表,再写正文 -- 只需输出 PRE_WRITE_CHECK、CHAPTER_TITLE、CHAPTER_CONTENT 三个区块`; + - 正文字数目标${params.wordCount}字,控制在${chapterLengthTarget.min}-${chapterLengthTarget.max}字区间 + - 如果篇幅偏长,优先压缩重复描写、解释性句子和过渡段,不要额外扩写 + - 先输出写作自检表,再写正文 + - 只需输出 PRE_WRITE_CHECK、CHAPTER_TITLE、CHAPTER_CONTENT 三个区块`; } private async loadRecentChapters( diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts index 30326909..81068661 100644 --- a/packages/core/src/llm/provider.ts +++ b/packages/core/src/llm/provider.ts @@ -108,6 +108,8 @@ export interface ChatWithToolsResult { readonly toolCalls: ReadonlyArray; } +type OpenAIChatTokenParam = "max_tokens" | "max_completion_tokens"; + // === Factory === export function createLLMClient(config: LLMConfig): LLMClient { @@ -142,6 +144,54 @@ export function createLLMClient(config: LLMConfig): LLMClient { }; } +function prefersMaxCompletionTokens(model: string): boolean { + return /^(gpt-5(?:\b|[-.])|o\d(?:\b|[-.]))/i.test(model); +} + +function getOpenAIChatTokenParamError(error: unknown): string | undefined { + const directParam = (error as { param?: unknown })?.param; + if (typeof directParam === "string") return directParam; + + const nestedParam = (error as { error?: { param?: unknown } })?.error?.param; + if (typeof nestedParam === "string") return nestedParam; + + return undefined; +} + +function isUnsupportedOpenAITokenParamError(error: unknown, tokenParam: OpenAIChatTokenParam): boolean { + const param = getOpenAIChatTokenParamError(error)?.toLowerCase(); + if (param === tokenParam) return true; + + const message = String(error).toLowerCase(); + return ( + (message.includes("unsupported parameter") || message.includes("unknown parameter")) + && message.includes(tokenParam) + ); +} + +async function createOpenAIChatCompletionWithTokenFallback( + model: string, + maxTokens: number, + baseParams: Record, + request: (params: Record) => Promise, +): Promise { + const primaryTokenParam: OpenAIChatTokenParam = prefersMaxCompletionTokens(model) + ? "max_completion_tokens" + : "max_tokens"; + const fallbackTokenParam: OpenAIChatTokenParam = primaryTokenParam === "max_tokens" + ? "max_completion_tokens" + : "max_tokens"; + + try { + return await request({ ...baseParams, [primaryTokenParam]: maxTokens }); + } catch (error) { + if (!isUnsupportedOpenAITokenParamError(error, primaryTokenParam)) { + throw error; + } + return await request({ ...baseParams, [fallbackTokenParam]: maxTokens }); + } +} + // === Partial Response (stream interrupted but usable content received) === export class PartialResponseError extends Error { @@ -328,18 +378,24 @@ async function chatCompletionOpenAIChat( webSearch?: boolean, onStreamProgress?: OnStreamProgress, ): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const createParams: any = { + // GPT-5 / reasoning-style chat models require max_completion_tokens, while + // older OpenAI-compatible APIs may still expect max_tokens. + const createParams = { model, messages: messages.map((m) => ({ role: m.role, content: m.content })), temperature: options.temperature, - max_tokens: options.maxTokens, stream: true, ...(webSearch ? { web_search_options: { search_context_size: "medium" as const } } : {}), ...options.extra, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const stream = await client.chat.completions.create(createParams) as any; + const stream = await createOpenAIChatCompletionWithTokenFallback( + model, + options.maxTokens, + createParams, + async (params) => await client.chat.completions.create( + params as unknown as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + ), + ) as AsyncIterable; const chunks: string[] = []; let inputTokens = 0; @@ -389,16 +445,21 @@ async function chatCompletionOpenAIChatSync( options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record }, _webSearch?: boolean, ): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const syncParams: any = { + const syncParams = { model, messages: messages.map((m) => ({ role: m.role, content: m.content })), temperature: options.temperature, - max_tokens: options.maxTokens, stream: false, ...options.extra, }; - const response = await client.chat.completions.create(syncParams); + const response = await createOpenAIChatCompletionWithTokenFallback( + model, + options.maxTokens, + syncParams, + async (params) => await client.chat.completions.create( + params as unknown as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming, + ), + ); const content = response.choices[0]?.message?.content ?? ""; if (!content) throw new Error("LLM returned empty response"); @@ -430,14 +491,20 @@ async function chatWithToolsOpenAIChat( }, })); - const stream = await client.chat.completions.create({ + const stream = await createOpenAIChatCompletionWithTokenFallback( model, - messages: openaiMessages, - tools: openaiTools, - temperature: options.temperature, - max_tokens: options.maxTokens, - stream: true, - }); + options.maxTokens, + { + model, + messages: openaiMessages, + tools: openaiTools, + temperature: options.temperature, + stream: true, + }, + async (params) => await client.chat.completions.create( + params as unknown as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + ), + ) as AsyncIterable; let content = ""; const toolCallMap = new Map(); diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 696c716c..b2967988 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -13,9 +13,10 @@ import { ReviserAgent, type ReviseMode } from "../agents/reviser.js"; import { StateValidatorAgent } from "../agents/state-validator.js"; import { RadarAgent } from "../agents/radar.js"; import type { RadarSource } from "../agents/radar-source.js"; -import { readGenreProfile } from "../agents/rules-reader.js"; +import { readGenreProfile, readBookRules } from "../agents/rules-reader.js"; import { analyzeAITells } from "../agents/ai-tells.js"; import { analyzeSensitiveWords } from "../agents/sensitive-words.js"; +import { validatePostWrite, type PostWriteViolation } from "../agents/post-write-validator.js"; import { StateManager } from "../state/manager.js"; import { dispatchNotification, dispatchWebhookEvent } from "../notify/dispatcher.js"; import type { WebhookEvent } from "../notify/webhook.js"; @@ -540,6 +541,8 @@ export class PipelineRunner { const bookDir = this.state.bookDir(bookId); const chapterNumber = await this.state.getNextChapterNumber(bookId); const { profile: gp } = await this.loadGenreProfile(book.genre); + const bookRules = (await readBookRules(bookDir))?.rules ?? null; + const targetWordCount = wordCount ?? book.chapterWordCount; // 1. Write chapter const writer = new WriterAgent(this.agentCtxFor("writer", bookId)); @@ -598,10 +601,19 @@ export class PipelineRunner { totalUsage = PipelineRunner.addUsage(totalUsage, llmAudit.tokenUsage); const aiTellsResult = analyzeAITells(finalContent); const sensitiveWriteResult = analyzeSensitiveWords(finalContent); + const deterministicIssues = validatePostWrite(finalContent, gp, bookRules, { + targetWordCount, + }); const hasBlockedWriteWords = sensitiveWriteResult.found.some((f) => f.severity === "block"); + const hasDeterministicErrors = deterministicIssues.some((issue) => issue.severity === "error"); let auditResult: AuditResult = { - passed: hasBlockedWriteWords ? false : llmAudit.passed, - issues: [...llmAudit.issues, ...aiTellsResult.issues, ...sensitiveWriteResult.issues], + passed: hasBlockedWriteWords || hasDeterministicErrors ? false : llmAudit.passed, + issues: [ + ...llmAudit.issues, + ...aiTellsResult.issues, + ...sensitiveWriteResult.issues, + ...PipelineRunner.toAuditIssues(deterministicIssues), + ], summary: llmAudit.summary, }; @@ -648,10 +660,19 @@ export class PipelineRunner { totalUsage = PipelineRunner.addUsage(totalUsage, reAudit.tokenUsage); const reAITells = analyzeAITells(finalContent); const reSensitive = analyzeSensitiveWords(finalContent); + const reDeterministicIssues = validatePostWrite(finalContent, gp, bookRules, { + targetWordCount, + }); const reHasBlocked = reSensitive.found.some((f) => f.severity === "block"); + const reHasDeterministicErrors = reDeterministicIssues.some((issue) => issue.severity === "error"); auditResult = { - passed: reHasBlocked ? false : reAudit.passed, - issues: [...reAudit.issues, ...reAITells.issues, ...reSensitive.issues], + passed: reHasBlocked || reHasDeterministicErrors ? false : reAudit.passed, + issues: [ + ...reAudit.issues, + ...reAITells.issues, + ...reSensitive.issues, + ...PipelineRunner.toAuditIssues(reDeterministicIssues), + ], summary: reAudit.summary, }; } @@ -1107,6 +1128,15 @@ ${matrix}`, }; } + private static toAuditIssues(violations: ReadonlyArray): ReadonlyArray { + return violations.map((violation) => ({ + severity: violation.severity === "error" ? "critical" : "warning", + category: violation.rule, + description: violation.description, + suggestion: violation.suggestion, + })); + } + private async buildPersistenceOutput( bookId: string, book: BookConfig, diff --git a/packages/core/src/utils/chapter-length.ts b/packages/core/src/utils/chapter-length.ts new file mode 100644 index 00000000..12de958f --- /dev/null +++ b/packages/core/src/utils/chapter-length.ts @@ -0,0 +1,32 @@ +export interface ChapterLengthTarget { + readonly target: number; + readonly min: number; + readonly max: number; + readonly tolerance: number; +} + +const LENGTH_TOLERANCE_STEP = 50; +const MIN_LENGTH_TOLERANCE = 200; + +export function getChapterLengthTarget(target: number): ChapterLengthTarget { + const roundedTolerance = Math.round((target * 0.1) / LENGTH_TOLERANCE_STEP) * LENGTH_TOLERANCE_STEP; + const tolerance = Math.max(MIN_LENGTH_TOLERANCE, roundedTolerance); + + return { + target, + min: Math.max(1, target - tolerance), + max: target + tolerance, + tolerance, + }; +} + +export function formatChapterLengthTarget( + target: ChapterLengthTarget, + language: "zh" | "en", +): string { + if (language === "en") { + return `target ${target.target} words, acceptable range ${target.min}-${target.max} words`; + } + + return `目标${target.target}字,允许区间${target.min}-${target.max}字`; +}