Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/core/src/__tests__/pipeline-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/__tests__/post-write-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
107 changes: 107 additions & 0 deletions packages/core/src/__tests__/provider.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>, 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<T>(items: ReadonlyArray<T>): AsyncIterable<T> {
return {
async *[Symbol.asyncIterator](): AsyncIterator<T> {
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<string, unknown>;
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<string, unknown>;
const secondParams = create.mock.calls[1]?.[0] as Record<string, unknown>;
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<string, unknown>;
expect(params.max_completion_tokens).toBe(24);
expect(params).not.toHaveProperty("max_tokens");
});
});
53 changes: 53 additions & 0 deletions packages/core/src/__tests__/writer-prompts.test.ts
Original file line number Diff line number Diff line change
@@ -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字左右");
});
});
16 changes: 9 additions & 7 deletions packages/core/src/agents/en-prompt-sections.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -91,15 +93,15 @@ 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?",
"Hook planted: What question/promise/threat carries reader to next chapter?",
"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?",
Expand All @@ -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.`;
}
41 changes: 40 additions & 1 deletion packages/core/src/agents/post-write-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,6 +16,10 @@ export interface PostWriteViolation {
readonly suggestion: string;
}

export interface PostWriteValidationOptions {
readonly targetWordCount?: number;
}

// --- Marker word lists ---

/** AI转折/惊讶标记词 */
Expand Down Expand Up @@ -52,14 +57,19 @@ export function validatePostWrite(
content: string,
genreProfile: GenreProfile,
bookRules: BookRules | null,
options: PostWriteValidationOptions = {},
): ReadonlyArray<PostWriteViolation> {
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. 硬性禁令: "不是…而是…" 句式
Expand Down Expand Up @@ -244,6 +254,35 @@ export function validatePostWrite(
return violations;
}

function validateChapterLength(content: string, targetWordCount: number): ReadonlyArray<PostWriteViolation> {
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,
Expand Down
Loading