Explorar o código

feat(pipeline): auto-repair collapsed chapter titles

Ma hai 7 meses
pai
achega
f3044007d4

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

@@ -259,4 +259,19 @@ describe("validatePostWrite", () => {
     expect(result.title).toContain("塔楼");
     expect(result.title).not.toBe("回声(2)");
   });
+
+  it("regenerates a title when it continues a collapsed recent title shell", () => {
+    const result = resolveDuplicateTitle(
+      "名单未落",
+      ["名单之前", "名单之后", "名单还在"],
+      "zh",
+      {
+        content: "塔楼里的铜铃只响了一声,守夜人没有回头,风从缺口灌进来。",
+      },
+    );
+
+    expect(result.issues.some((issue) => issue.rule === "title-collapse")).toBe(true);
+    expect(result.title).not.toContain("名单");
+    expect(result.title).toContain("塔楼");
+  });
 });

+ 97 - 15
packages/core/src/agents/post-write-validator.ts

@@ -5,6 +5,7 @@
  * Catches violations that prompt-only rules cannot guarantee.
  */
 
+import { analyzeChapterCadence } from "../utils/chapter-cadence.js";
 import type { BookRules } from "../models/book-rules.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 
@@ -636,28 +637,89 @@ export function resolveDuplicateTitle(
     return { title: newTitle, issues: [] };
   }
 
-  const issues = detectDuplicateTitle(trimmed, existingTitles);
-  if (issues.length === 0) {
+  const duplicateIssues = detectDuplicateTitle(trimmed, existingTitles);
+  if (duplicateIssues.length > 0) {
+    const regenerated = regenerateDuplicateTitle(trimmed, existingTitles, language, options?.content);
+    if (regenerated && detectDuplicateTitle(regenerated, existingTitles).length === 0) {
+      return { title: regenerated, issues: duplicateIssues };
+    }
+
+    let counter = 2;
+    while (counter < 100) {
+      const candidate = language === "en"
+        ? `${trimmed} (${counter})`
+        : `${trimmed}(${counter})`;
+      if (detectDuplicateTitle(candidate, existingTitles).length === 0) {
+        return { title: candidate, issues: duplicateIssues };
+      }
+      counter++;
+    }
+
+    return { title: trimmed, issues: duplicateIssues };
+  }
+
+  const collapseIssues = detectTitleCollapse(trimmed, existingTitles, language);
+  if (collapseIssues.length === 0) {
     return { title: trimmed, issues: [] };
   }
 
-  const regenerated = regenerateDuplicateTitle(trimmed, existingTitles, language, options?.content);
-  if (regenerated && detectDuplicateTitle(regenerated, existingTitles).length === 0) {
-    return { title: regenerated, issues };
+  const regenerated = regenerateCollapsedTitle(trimmed, existingTitles, language, options?.content);
+  if (
+    regenerated
+    && detectDuplicateTitle(regenerated, existingTitles).length === 0
+    && detectTitleCollapse(regenerated, existingTitles, language).length === 0
+  ) {
+    return { title: regenerated, issues: collapseIssues };
   }
 
-  let counter = 2;
-  while (counter < 100) {
-    const candidate = language === "en"
-      ? `${trimmed} (${counter})`
-      : `${trimmed}(${counter})`;
-    if (detectDuplicateTitle(candidate, existingTitles).length === 0) {
-      return { title: candidate, issues };
-    }
-    counter++;
+  return { title: trimmed, issues: collapseIssues };
+}
+
+function detectTitleCollapse(
+  newTitle: string,
+  existingTitles: ReadonlyArray<string>,
+  language: "zh" | "en",
+): ReadonlyArray<PostWriteViolation> {
+  const recentTitles = existingTitles
+    .map((title) => title.trim())
+    .filter(Boolean)
+    .slice(-3);
+  if (recentTitles.length < 3) {
+    return [];
   }
 
-  return { title: trimmed, issues };
+  const cadence = analyzeChapterCadence({
+    language,
+    rows: [...recentTitles, newTitle].map((title, index) => ({
+      chapter: index + 1,
+      title,
+      mood: "",
+      chapterType: "",
+    })),
+  });
+  const titlePressure = cadence.titlePressure;
+  if (!titlePressure || titlePressure.pressure !== "high") {
+    return [];
+  }
+  if (!newTitle.includes(titlePressure.repeatedToken)) {
+    return [];
+  }
+
+  return [
+    language === "en"
+      ? {
+          rule: "title-collapse",
+          severity: "warning",
+          description: `Chapter title "${newTitle}" keeps leaning on the recent "${titlePressure.repeatedToken}" title shell.`,
+          suggestion: "Rename the chapter around a new image, action, consequence, or character focus.",
+        }
+      : {
+          rule: "title-collapse",
+          severity: "warning",
+          description: `章节标题"${newTitle}"仍在沿用近期围绕“${titlePressure.repeatedToken}”的命名壳。`,
+          suggestion: "换一个新的意象、动作、后果或人物焦点来命名。",
+        },
+  ];
 }
 
 function regenerateDuplicateTitle(
@@ -682,6 +744,26 @@ function regenerateDuplicateTitle(
     : `${baseTitle}:${qualifier}`;
 }
 
+function regenerateCollapsedTitle(
+  baseTitle: string,
+  existingTitles: ReadonlyArray<string>,
+  language: "zh" | "en",
+  content?: string,
+): string | undefined {
+  if (!content || !content.trim()) {
+    return undefined;
+  }
+
+  const fresh = language === "en"
+    ? extractEnglishTitleQualifier(baseTitle, existingTitles, content)
+    : extractChineseTitleQualifier(baseTitle, existingTitles, content);
+  if (!fresh) {
+    return undefined;
+  }
+
+  return fresh === baseTitle ? undefined : fresh;
+}
+
 function extractEnglishTitleQualifier(
   baseTitle: string,
   existingTitles: ReadonlyArray<string>,

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

@@ -1217,8 +1217,8 @@ export class PipelineRunner {
     }
     if (persistenceOutput.title !== output.title) {
       const description = pipelineLang === "en"
-        ? `Duplicate chapter title "${output.title}" was auto-renamed to "${persistenceOutput.title}".`
-        : `章节标题"${output.title}"与已有标题重复,已自动改为"${persistenceOutput.title}"。`;
+        ? `Chapter title "${output.title}" was auto-adjusted to "${persistenceOutput.title}".`
+        : `章节标题"${output.title}"已自动调整为"${persistenceOutput.title}"。`;
       this.config.logger?.warn(`[title] ${description}`);
       auditResult = {
         ...auditResult,