Преглед изворни кода

fix(titles): regenerate duplicates before suffix fallback

Ma пре 7 месеци
родитељ
комит
c5af09f897

+ 58 - 2
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -3243,8 +3243,8 @@ describe("PipelineRunner", () => {
       createWriterOutput({
       createWriterOutput({
         chapterNumber: 2,
         chapterNumber: 2,
         title: "回声",
         title: "回声",
-        content: "这次的正文完全不同,只是标题碰巧重复了。",
-        wordCount: "这次的正文完全不同,只是标题碰巧重复了。".length,
+        content: "啊。",
+        wordCount: "啊。".length,
       }),
       }),
     );
     );
     vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
     vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
@@ -3266,6 +3266,62 @@ describe("PipelineRunner", () => {
     }
     }
   });
   });
 
 
+  it("regenerates duplicate chapter titles before falling back to numeric suffixes", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const now = "2026-03-19T00:00:00.000Z";
+
+    await Promise.all([
+      writeFile(join(chaptersDir, "0001_回声.md"), "# 第1章 回声\n\n旧章节。", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 1,
+        location: "Ashen ferry crossing",
+        protagonistState: "Lin Yue still hides the oath token.",
+        goal: "Find the vanished mentor.",
+        conflict: "The debt trail keeps narrowing.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [{
+      number: 1,
+      title: "回声",
+      status: "ready-for-review",
+      wordCount: 12,
+      createdAt: now,
+      updatedAt: now,
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 2,
+        title: "回声",
+        content: "塔楼里的铜铃只响了一声,风从缺口灌进来,守夜人没有回头。",
+        wordCount: "塔楼里的铜铃只响了一声,风从缺口灌进来,守夜人没有回头。".length,
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+
+    try {
+      const result = await runner.writeNextChapter(bookId, 120);
+      const index = await state.loadChapterIndex(bookId);
+
+      expect(result.title).toContain("塔楼");
+      expect(result.title).not.toBe("回声(2)");
+      expect(index.at(-1)?.title).toBe(result.title);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("defaults manual reviseDraft to spot-fix when mode is omitted", async () => {
   it("defaults manual reviseDraft to spot-fix when mode is omitted", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const { root, runner, state, bookId } = await createRunnerFixture();
     const storyDir = join(state.bookDir(bookId), "story");
     const storyDir = join(state.bookDir(bookId), "story");

+ 15 - 0
packages/core/src/__tests__/post-write-validator.test.ts

@@ -3,6 +3,7 @@ import {
   detectDuplicateTitle,
   detectDuplicateTitle,
   detectParagraphLengthDrift,
   detectParagraphLengthDrift,
   detectParagraphShapeWarnings,
   detectParagraphShapeWarnings,
+  resolveDuplicateTitle,
   validatePostWrite,
   validatePostWrite,
   type PostWriteViolation,
   type PostWriteViolation,
 } from "../agents/post-write-validator.js";
 } from "../agents/post-write-validator.js";
@@ -244,4 +245,18 @@ describe("validatePostWrite", () => {
     const result = detectDuplicateTitle("Echo-2", ["Echo 2"]);
     const result = detectDuplicateTitle("Echo-2", ["Echo 2"]);
     expect(findRule(result, "near-duplicate-title")).toBeDefined();
     expect(findRule(result, "near-duplicate-title")).toBeDefined();
   });
   });
+
+  it("prefers regenerating a duplicate title from chapter content before numeric suffix fallback", () => {
+    const result = resolveDuplicateTitle(
+      "回声",
+      ["旧路", "回声"],
+      "zh",
+      {
+        content: "塔楼里的铜铃只响了一声,风从缺口灌进来,守夜人没有回头。",
+      },
+    );
+
+    expect(result.title).toContain("塔楼");
+    expect(result.title).not.toBe("回声(2)");
+  });
 });
 });

+ 116 - 0
packages/core/src/agents/post-write-validator.ts

@@ -561,6 +561,21 @@ const ENGLISH_NAME_STOP_WORDS = new Set([
   "They",
   "They",
 ]);
 ]);
 
 
+const CHINESE_TITLE_STOP_WORDS = new Set([
+  "这次",
+  "正文",
+  "标题",
+  "重复",
+  "不同",
+  "完全",
+  "只是",
+  "碰巧",
+  "没有",
+  "回头",
+]);
+
+const CHINESE_TITLE_STOP_CHARS = new Set(["的", "了", "着", "一", "只", "从", "在", "和", "与", "把", "被", "有", "没", "里", "又", "才"]);
+
 /**
 /**
  * Detect duplicate or near-duplicate chapter titles.
  * Detect duplicate or near-duplicate chapter titles.
  * Compares the new title against existing chapter titles from index.
  * Compares the new title against existing chapter titles from index.
@@ -609,6 +624,9 @@ export function resolveDuplicateTitle(
   newTitle: string,
   newTitle: string,
   existingTitles: ReadonlyArray<string>,
   existingTitles: ReadonlyArray<string>,
   language: "zh" | "en" = "zh",
   language: "zh" | "en" = "zh",
+  options?: {
+    readonly content?: string;
+  },
 ): {
 ): {
   readonly title: string;
   readonly title: string;
   readonly issues: ReadonlyArray<PostWriteViolation>;
   readonly issues: ReadonlyArray<PostWriteViolation>;
@@ -623,6 +641,11 @@ export function resolveDuplicateTitle(
     return { title: trimmed, issues: [] };
     return { title: trimmed, issues: [] };
   }
   }
 
 
+  const regenerated = regenerateDuplicateTitle(trimmed, existingTitles, language, options?.content);
+  if (regenerated && detectDuplicateTitle(regenerated, existingTitles).length === 0) {
+    return { title: regenerated, issues };
+  }
+
   let counter = 2;
   let counter = 2;
   while (counter < 100) {
   while (counter < 100) {
     const candidate = language === "en"
     const candidate = language === "en"
@@ -636,3 +659,96 @@ export function resolveDuplicateTitle(
 
 
   return { title: trimmed, issues };
   return { title: trimmed, issues };
 }
 }
+
+function regenerateDuplicateTitle(
+  baseTitle: string,
+  existingTitles: ReadonlyArray<string>,
+  language: "zh" | "en",
+  content?: string,
+): string | undefined {
+  if (!content || !content.trim()) {
+    return undefined;
+  }
+
+  const qualifier = language === "en"
+    ? extractEnglishTitleQualifier(baseTitle, existingTitles, content)
+    : extractChineseTitleQualifier(baseTitle, existingTitles, content);
+  if (!qualifier) {
+    return undefined;
+  }
+
+  return language === "en"
+    ? `${baseTitle}: ${qualifier}`
+    : `${baseTitle}:${qualifier}`;
+}
+
+function extractEnglishTitleQualifier(
+  baseTitle: string,
+  existingTitles: ReadonlyArray<string>,
+  content: string,
+): string | undefined {
+  const blocked = new Set(extractEnglishTitleTerms([baseTitle, ...existingTitles].join(" ")));
+  const words = (content.match(/[A-Za-z]{4,}/g) ?? [])
+    .map((word) => word.toLowerCase())
+    .filter((word) => !ENGLISH_NAME_STOP_WORDS.has(capitalize(word)))
+    .filter((word) => !blocked.has(word));
+  const first = words[0];
+  if (!first) {
+    return undefined;
+  }
+
+  const second = words.find((word) => word !== first && !blocked.has(word));
+  return second
+    ? `${capitalize(first)} ${capitalize(second)}`
+    : capitalize(first);
+}
+
+function extractChineseTitleQualifier(
+  baseTitle: string,
+  existingTitles: ReadonlyArray<string>,
+  content: string,
+): string | undefined {
+  const blocked = new Set(extractChineseTitleTerms([baseTitle, ...existingTitles].join("")));
+  const segments = content.match(/[\u4e00-\u9fff]+/g) ?? [];
+
+  for (const segment of segments) {
+    for (let start = 0; start < segment.length; start += 1) {
+      for (let size = 2; size <= 4; size += 1) {
+        const candidate = segment.slice(start, start + size).trim();
+        if (candidate.length < 2) continue;
+        if (CHINESE_TITLE_STOP_WORDS.has(candidate)) continue;
+        if ([...candidate].some((char) => CHINESE_TITLE_STOP_CHARS.has(char))) continue;
+        if (blocked.has(candidate)) continue;
+        return candidate;
+      }
+    }
+  }
+
+  return undefined;
+}
+
+function extractEnglishTitleTerms(text: string): string[] {
+  return [...new Set((text.match(/[A-Za-z]{4,}/g) ?? []).map((word) => word.toLowerCase()))];
+}
+
+function extractChineseTitleTerms(text: string): string[] {
+  const terms = new Set<string>();
+  const segments = text.match(/[\u4e00-\u9fff]+/g) ?? [];
+
+  for (const segment of segments) {
+    for (let start = 0; start < segment.length; start += 1) {
+      for (let size = 2; size <= 4; size += 1) {
+        const candidate = segment.slice(start, start + size).trim();
+        if (candidate.length < 2) continue;
+        if ([...candidate].some((char) => CHINESE_TITLE_STOP_CHARS.has(char))) continue;
+        terms.add(candidate);
+      }
+    }
+  }
+
+  return [...terms];
+}
+
+function capitalize(word: string): string {
+  return word.length === 0 ? word : `${word[0]!.toUpperCase()}${word.slice(1)}`;
+}

+ 2 - 2
packages/core/src/agents/writer-prompts.ts

@@ -535,7 +535,7 @@ ${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) |
 ${preWriteTable}
 ${preWriteTable}
 
 
 === CHAPTER_TITLE ===
 === CHAPTER_TITLE ===
-(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题)
+(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,必须主动避开重复词根和高频意象)
 
 
 === CHAPTER_CONTENT ===
 === CHAPTER_CONTENT ===
 (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字)
 (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字)
@@ -588,7 +588,7 @@ ${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) |
 ${preWriteTable}
 ${preWriteTable}
 
 
 === CHAPTER_TITLE ===
 === CHAPTER_TITLE ===
-(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题)
+(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,必须主动避开重复词根和高频意象)
 
 
 === CHAPTER_CONTENT ===
 === CHAPTER_CONTENT ===
 (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字)
 (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字)

+ 2 - 0
packages/core/src/pipeline/runner.ts

@@ -1135,6 +1135,7 @@ export class PipelineRunner {
       output.title,
       output.title,
       chapterIndexBeforePersist.map((chapter) => chapter.title),
       chapterIndexBeforePersist.map((chapter) => chapter.title),
       pipelineLang,
       pipelineLang,
+      { content: finalContent },
     );
     );
     let persistenceOutput = await this.buildPersistenceOutput(
     let persistenceOutput = await this.buildPersistenceOutput(
       bookId,
       bookId,
@@ -1152,6 +1153,7 @@ export class PipelineRunner {
       persistenceOutput.title,
       persistenceOutput.title,
       chapterIndexBeforePersist.map((chapter) => chapter.title),
       chapterIndexBeforePersist.map((chapter) => chapter.title),
       pipelineLang,
       pipelineLang,
+      { content: finalContent },
     );
     );
     if (finalTitleResolution.title !== persistenceOutput.title) {
     if (finalTitleResolution.title !== persistenceOutput.title) {
       persistenceOutput = {
       persistenceOutput = {