From aeb6d2c1712ff10822e1d54a45eb3b71cab8e024 Mon Sep 17 00:00:00 2001 From: Ma Date: Sat, 28 Mar 2026 20:38:21 +0800 Subject: [PATCH] fix: detect duplicate chapter titles in prompt and post-write validation - Writer prompt: add "title must differ from existing chapters" constraint - post-write-validator: add detectDuplicateTitle() for exact and near-duplicate matching - runner: check title uniqueness against chapter index before persist Addresses #94 --- .../core/src/agents/post-write-validator.ts | 44 +++++++++++++++++++ packages/core/src/agents/writer-prompts.ts | 4 +- packages/core/src/pipeline/runner.ts | 22 ++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agents/post-write-validator.ts b/packages/core/src/agents/post-write-validator.ts index 8d774bc5..94910dd6 100644 --- a/packages/core/src/agents/post-write-validator.ts +++ b/packages/core/src/agents/post-write-validator.ts @@ -544,3 +544,47 @@ const ENGLISH_NAME_STOP_WORDS = new Set([ "Then", "They", ]); + +/** + * Detect duplicate or near-duplicate chapter titles. + * Compares the new title against existing chapter titles from index. + */ +export function detectDuplicateTitle( + newTitle: string, + existingTitles: ReadonlyArray, +): ReadonlyArray { + if (!newTitle.trim()) return []; + + const normalized = newTitle.trim().toLowerCase(); + const violations: PostWriteViolation[] = []; + + for (const existing of existingTitles) { + const existingNorm = existing.trim().toLowerCase(); + if (!existingNorm) continue; + + // Exact match + if (normalized === existingNorm) { + violations.push({ + rule: "duplicate-title", + severity: "warning", + description: `章节标题"${newTitle}"与已有章节标题完全相同`, + suggestion: "更换一个不同的章节标题", + }); + break; + } + + // Near-duplicate: one is substring of the other, or only differs by punctuation/numbers + const stripPunct = (s: string) => s.replace(/[^\p{L}\p{N}]/gu, ""); + if (stripPunct(normalized) === stripPunct(existingNorm)) { + violations.push({ + rule: "near-duplicate-title", + severity: "warning", + description: `章节标题"${newTitle}"与已有标题"${existing}"高度相似`, + suggestion: "避免使用相似的章节标题", + }); + break; + } + } + + return violations; +} diff --git a/packages/core/src/agents/writer-prompts.ts b/packages/core/src/agents/writer-prompts.ts index 7ba43b51..ddf82db0 100644 --- a/packages/core/src/agents/writer-prompts.ts +++ b/packages/core/src/agents/writer-prompts.ts @@ -535,7 +535,7 @@ ${resourceRow}| 待回收伏笔 | Hook-A / Hook-B | 与伏笔池一致 | ${preWriteTable} === CHAPTER_TITLE === -(章节标题,不含"第X章") +(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题) === CHAPTER_CONTENT === (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字) @@ -588,7 +588,7 @@ ${resourceRow}| 待回收伏笔 | Hook-A / Hook-B | 与伏笔池一致 | ${preWriteTable} === CHAPTER_TITLE === -(章节标题,不含"第X章") +(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题) === CHAPTER_CONTENT === (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字) diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 1b828c95..32145fac 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -1204,6 +1204,28 @@ export class PipelineRunner { } } + // 4.3 Title dedup check + { + const { detectDuplicateTitle } = await import("../agents/post-write-validator.js"); + const chapterIndex = await this.state.loadChapterIndex(bookId); + const existingTitles = chapterIndex.map((ch) => ch.title); + const titleIssues = detectDuplicateTitle(persistenceOutput.title, existingTitles); + if (titleIssues.length > 0) { + for (const issue of titleIssues) { + this.config.logger?.warn(`[title] ${issue.description}`); + } + auditResult = { + ...auditResult, + issues: [...auditResult.issues, ...titleIssues.map((v) => ({ + severity: v.severity as "warning", + category: "title-dedup", + description: v.description, + suggestion: v.suggestion, + }))], + }; + } + } + await writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang); await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang); await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput);