Explorar o código

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
Ma hai 7 meses
pai
achega
aeb6d2c171

+ 44 - 0
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<string>,
+): ReadonlyArray<PostWriteViolation> {
+  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;
+}

+ 2 - 2
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}字)

+ 22 - 0
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);