Przeglądaj źródła

feat(pipeline): unify chapter cadence pressure analysis

Ma 7 miesięcy temu
rodzic
commit
c608cd3237

+ 87 - 0
packages/core/src/__tests__/chapter-cadence.test.ts

@@ -0,0 +1,87 @@
+import { describe, expect, it } from "vitest";
+import { analyzeChapterCadence } from "../utils/chapter-cadence.js";
+
+describe("analyzeChapterCadence", () => {
+  it("surfaces stacked scene, mood, and title pressure from recent summary rows", () => {
+    const analysis = analyzeChapterCadence({
+      language: "zh",
+      rows: [
+        {
+          chapter: 1,
+          title: "名单之前",
+          mood: "紧张、压抑",
+          chapterType: "调查章",
+        },
+        {
+          chapter: 2,
+          title: "名单之后",
+          mood: "冷硬、逼仄",
+          chapterType: "调查章",
+        },
+        {
+          chapter: 3,
+          title: "名单还在",
+          mood: "压迫、窒息",
+          chapterType: "调查章",
+        },
+        {
+          chapter: 4,
+          title: "名单未落",
+          mood: "肃杀、凝重",
+          chapterType: "调查章",
+        },
+      ],
+    });
+
+    expect(analysis.scenePressure).toEqual(expect.objectContaining({
+      repeatedType: "调查章",
+      pressure: "high",
+      streak: 4,
+    }));
+    expect(analysis.moodPressure).toEqual(expect.objectContaining({
+      pressure: "high",
+      highTensionStreak: 4,
+    }));
+    expect(analysis.titlePressure).toEqual(expect.objectContaining({
+      repeatedToken: "名单",
+      pressure: "high",
+      count: 4,
+    }));
+  });
+
+  it("stays quiet when scene, mood, and title cadence are varied", () => {
+    const analysis = analyzeChapterCadence({
+      language: "en",
+      rows: [
+        {
+          chapter: 1,
+          title: "Morning Harbor",
+          mood: "warm, gentle",
+          chapterType: "slice-of-life",
+        },
+        {
+          chapter: 2,
+          title: "Sudden Rain",
+          mood: "tense, ominous",
+          chapterType: "tension",
+        },
+        {
+          chapter: 3,
+          title: "Open Gate",
+          mood: "hopeful, light",
+          chapterType: "transition",
+        },
+        {
+          chapter: 4,
+          title: "After the Letter",
+          mood: "melancholy, reflective",
+          chapterType: "introspection",
+        },
+      ],
+    });
+
+    expect(analysis.scenePressure).toBeUndefined();
+    expect(analysis.moodPressure).toBeUndefined();
+    expect(analysis.titlePressure).toBeUndefined();
+  });
+});

+ 36 - 0
packages/core/src/__tests__/long-span-fatigue.test.ts

@@ -121,4 +121,40 @@ describe("analyzeLongSpanFatigue", () => {
       await rm(join(bookDir, ".."), { recursive: true, force: true });
     }
   });
+
+  it("warns when title focus collapses and high-tension mood never releases", async () => {
+    const bookDir = await createBookDir("inkos-long-span-cadence-test-");
+
+    await Promise.all([
+      writeChapter(bookDir, 1, "名单之前", "风贴着走廊吹。周谨川没有停,手指一直压着那份发潮的薄册。"),
+      writeChapter(bookDir, 2, "名单之后", "楼道里只有脚步声。周谨川顺着裂灯往下走,肩背始终绷着。"),
+      writeFile(
+        join(bookDir, "story", "chapter_summaries.md"),
+        [
+          "# 章节摘要",
+          "",
+          "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+          "|------|------|----------|----------|----------|----------|----------|----------|",
+          "| 1 | 名单之前 | 周谨川 | 初次接触名单 | 压力上升 | 名单线继续发酵 | 紧张、压抑 | 调查章 |",
+          "| 2 | 名单之后 | 周谨川 | 顺着名单继续追查 | 目标未变 | 名单线继续发酵 | 冷硬、逼仄 | 调查章 |",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    try {
+      const result = await analyzeLongSpanFatigue({
+        bookDir,
+        chapterNumber: 3,
+        chapterContent: "墙角的灰一直没落定。周谨川盯着名单最后一行,喉结很轻地滚了一下,还是没有把气松出来。",
+        chapterSummary: "| 3 | 名单未落 | 周谨川 | 名单追查继续推进 | 目标未变 | 名单线继续发酵 | 压迫、窒息 | 调查章 |",
+        language: "zh",
+      });
+
+      expect(result.issues.some((issue) => issue.category === "标题重复")).toBe(true);
+      expect(result.issues.some((issue) => issue.category === "情绪单调")).toBe(true);
+    } finally {
+      await rm(join(bookDir, ".."), { recursive: true, force: true });
+    }
+  });
 });

+ 24 - 61
packages/core/src/agents/planner.ts

@@ -11,6 +11,7 @@ import {
   renderSummarySnapshot,
   retrieveMemorySelection,
 } from "../utils/memory-retrieval.js";
+import { analyzeChapterCadence } from "../utils/chapter-cadence.js";
 
 export interface PlanChapterInput {
   readonly book: BookConfig;
@@ -138,8 +139,17 @@ export class PlannerAgent extends BaseAgent {
   }): Pick<ChapterIntent, "sceneDirective" | "arcDirective" | "moodDirective" | "titleDirective"> {
     const recentSummaries = parseChapterSummariesMarkdown(input.chapterSummaries)
       .filter((summary) => summary.chapter < input.chapterNumber)
-      .sort((left, right) => right.chapter - left.chapter)
-      .slice(0, 4);
+      .sort((left, right) => left.chapter - right.chapter)
+      .slice(-4);
+    const cadence = analyzeChapterCadence({
+      language: this.isChineseLanguage(input.language) ? "zh" : "en",
+      rows: recentSummaries.map((summary) => ({
+        chapter: summary.chapter,
+        title: summary.title,
+        mood: summary.mood,
+        chapterType: summary.chapterType,
+      })),
+    });
 
     return {
       arcDirective: this.buildArcDirective(
@@ -148,9 +158,9 @@ export class PlannerAgent extends BaseAgent {
         input.outlineNode,
         input.matchedOutlineAnchor,
       ),
-      sceneDirective: this.buildSceneDirective(input.language, recentSummaries),
-      moodDirective: this.buildMoodDirective(input.language, recentSummaries),
-      titleDirective: this.buildTitleDirective(input.language, recentSummaries),
+      sceneDirective: this.buildSceneDirective(input.language, cadence),
+      moodDirective: this.buildMoodDirective(input.language, cadence),
+      titleDirective: this.buildTitleDirective(input.language, cadence),
     };
   }
 
@@ -334,15 +344,12 @@ export class PlannerAgent extends BaseAgent {
 
   private buildSceneDirective(
     language: string | undefined,
-    recentSummaries: ReadonlyArray<{ chapterType: string }>,
+    cadence: ReturnType<typeof analyzeChapterCadence>,
   ): string | undefined {
-    const repeatedType = this.findRepeatedValue(
-      recentSummaries.map((summary) => summary.chapterType),
-      3,
-    );
-    if (!repeatedType) {
+    if (cadence.scenePressure?.pressure !== "high") {
       return undefined;
     }
+    const repeatedType = cadence.scenePressure.repeatedType;
 
     return this.isChineseLanguage(language)
       ? `最近章节连续停留在“${repeatedType}”,本章必须更换场景容器、地点或行动方式。`
@@ -351,76 +358,32 @@ export class PlannerAgent extends BaseAgent {
 
   private buildMoodDirective(
     language: string | undefined,
-    recentSummaries: ReadonlyArray<{ mood: string }>,
+    cadence: ReturnType<typeof analyzeChapterCadence>,
   ): string | undefined {
-    if (recentSummaries.length < 3) {
-      return undefined;
-    }
-
-    const moods = recentSummaries.map((summary) => summary.mood.trim()).filter(Boolean);
-    if (moods.length < 3) {
-      return undefined;
-    }
-
-    const allHighTension = moods.every((mood) => this.isHighTensionMood(mood));
-    if (!allHighTension) {
+    if (cadence.moodPressure?.pressure !== "high") {
       return undefined;
     }
+    const moods = cadence.moodPressure.recentMoods;
 
     return this.isChineseLanguage(language)
       ? `最近${moods.length}章情绪持续高压(${moods.slice(0, 3).join("、")}),本章必须降调——安排日常/喘息/温情/幽默场景,让读者呼吸。`
       : `The last ${moods.length} chapters have been relentlessly tense (${moods.slice(0, 3).join(", ")}). This chapter must downshift — write a quieter scene with warmth, humor, or breathing room.`;
   }
 
-  private isHighTensionMood(mood: string): boolean {
-    const tensionKeywords = [
-      "紧张", "冷硬", "压抑", "逼仄", "肃杀", "沉重", "凝重",
-      "冷峻", "压迫", "阴沉", "焦灼", "窒息", "凛冽", "锋利",
-      "克制", "危机", "对峙", "绷紧", "僵持", "杀意",
-      "tense", "cold", "oppressive", "grim", "ominous", "dark",
-      "bleak", "hostile", "threatening", "heavy", "suffocating",
-    ];
-    const lowerMood = mood.toLowerCase();
-    return tensionKeywords.some((keyword) => lowerMood.includes(keyword));
-  }
-
   private buildTitleDirective(
     language: string | undefined,
-    recentSummaries: ReadonlyArray<{ title: string }>,
+    cadence: ReturnType<typeof analyzeChapterCadence>,
   ): string | undefined {
-    const tokenCounts = new Map<string, number>();
-
-    for (const summary of recentSummaries) {
-      for (const token of this.extractKeywords(summary.title)) {
-        tokenCounts.set(token, (tokenCounts.get(token) ?? 0) + 1);
-      }
-    }
-
-    const repeatedToken = [...tokenCounts.entries()]
-      .sort((left, right) => right[1] - left[1])
-      .find((entry) => entry[1] >= 3)?.[0];
-    if (!repeatedToken) {
+    if (cadence.titlePressure?.pressure !== "high") {
       return undefined;
     }
+    const repeatedToken = cadence.titlePressure.repeatedToken;
 
     return this.isChineseLanguage(language)
       ? `标题不要再围绕“${repeatedToken}”重复命名,换一个新的意象或动作焦点。`
       : `Avoid another ${repeatedToken}-centric title. Pick a new image or action focus for this chapter title.`;
   }
 
-  private findRepeatedValue(values: ReadonlyArray<string>, threshold: number): string | undefined {
-    const counts = new Map<string, number>();
-
-    for (const value of values.map((value) => value.trim()).filter(Boolean)) {
-      counts.set(value, (counts.get(value) ?? 0) + 1);
-      if ((counts.get(value) ?? 0) >= threshold) {
-        return value;
-      }
-    }
-
-    return undefined;
-  }
-
   private extractSection(content: string, headings: ReadonlyArray<string>): string | undefined {
     const targets = headings.map((heading) => this.normalizeHeading(heading));
     const lines = content.split("\n");

+ 189 - 0
packages/core/src/utils/chapter-cadence.ts

@@ -0,0 +1,189 @@
+export interface CadenceSummaryRow {
+  readonly chapter: number;
+  readonly title: string;
+  readonly mood: string;
+  readonly chapterType: string;
+}
+
+export interface SceneCadencePressure {
+  readonly pressure: "medium" | "high";
+  readonly repeatedType: string;
+  readonly streak: number;
+}
+
+export interface MoodCadencePressure {
+  readonly pressure: "medium" | "high";
+  readonly highTensionStreak: number;
+  readonly recentMoods: ReadonlyArray<string>;
+}
+
+export interface TitleCadencePressure {
+  readonly pressure: "medium" | "high";
+  readonly repeatedToken: string;
+  readonly count: number;
+  readonly recentTitles: ReadonlyArray<string>;
+}
+
+export interface ChapterCadenceAnalysis {
+  readonly scenePressure?: SceneCadencePressure;
+  readonly moodPressure?: MoodCadencePressure;
+  readonly titlePressure?: TitleCadencePressure;
+}
+
+const HIGH_TENSION_KEYWORDS = [
+  "紧张", "冷硬", "压抑", "逼仄", "肃杀", "沉重", "凝重",
+  "冷峻", "压迫", "阴沉", "焦灼", "窒息", "凛冽", "锋利",
+  "克制", "危机", "对峙", "绷紧", "僵持", "杀意",
+  "tense", "cold", "oppressive", "grim", "ominous", "dark",
+  "bleak", "hostile", "threatening", "heavy", "suffocating",
+];
+
+const ENGLISH_STOP_WORDS = new Set([
+  "the", "and", "with", "from", "into", "after", "before",
+  "over", "under", "this", "that", "your", "their",
+]);
+
+export function analyzeChapterCadence(params: {
+  readonly rows: ReadonlyArray<CadenceSummaryRow>;
+  readonly language: "zh" | "en";
+}): ChapterCadenceAnalysis {
+  const recentRows = [...params.rows]
+    .sort((left, right) => left.chapter - right.chapter)
+    .slice(-4);
+
+  return {
+    scenePressure: analyzeScenePressure(recentRows),
+    moodPressure: analyzeMoodPressure(recentRows),
+    titlePressure: analyzeTitlePressure(recentRows, params.language),
+  };
+}
+
+export function isHighTensionMood(mood: string): boolean {
+  const lowerMood = mood.toLowerCase();
+  return HIGH_TENSION_KEYWORDS.some((keyword) => lowerMood.includes(keyword));
+}
+
+function analyzeScenePressure(
+  rows: ReadonlyArray<CadenceSummaryRow>,
+): SceneCadencePressure | undefined {
+  const types = rows
+    .map((row) => row.chapterType.trim())
+    .filter((value) => isMeaningfulValue(value));
+  if (types.length < 2) {
+    return undefined;
+  }
+
+  const repeatedType = types.at(-1);
+  if (!repeatedType) {
+    return undefined;
+  }
+
+  let streak = 0;
+  for (const type of [...types].reverse()) {
+    if (type.toLowerCase() !== repeatedType.toLowerCase()) {
+      break;
+    }
+    streak += 1;
+  }
+
+  if (streak >= 3) {
+    return { pressure: "high", repeatedType, streak };
+  }
+  if (streak >= 2 && types.length >= 4) {
+    return { pressure: "medium", repeatedType, streak };
+  }
+  return undefined;
+}
+
+function analyzeMoodPressure(
+  rows: ReadonlyArray<CadenceSummaryRow>,
+): MoodCadencePressure | undefined {
+  const moods = rows
+    .map((row) => row.mood.trim())
+    .filter((value) => isMeaningfulValue(value));
+  if (moods.length < 2) {
+    return undefined;
+  }
+
+  const recentMoods: string[] = [];
+  let highTensionStreak = 0;
+  for (const mood of [...moods].reverse()) {
+    if (!isHighTensionMood(mood)) {
+      break;
+    }
+    recentMoods.unshift(mood);
+    highTensionStreak += 1;
+  }
+
+  if (highTensionStreak >= 3) {
+    return { pressure: "high", highTensionStreak, recentMoods };
+  }
+  if (highTensionStreak >= 2 && moods.length >= 4) {
+    return { pressure: "medium", highTensionStreak, recentMoods };
+  }
+  return undefined;
+}
+
+function analyzeTitlePressure(
+  rows: ReadonlyArray<CadenceSummaryRow>,
+  language: "zh" | "en",
+): TitleCadencePressure | undefined {
+  const titles = rows
+    .map((row) => row.title.trim())
+    .filter((value) => isMeaningfulValue(value));
+  if (titles.length < 2) {
+    return undefined;
+  }
+
+  const counts = new Map<string, number>();
+  for (const title of titles) {
+    for (const token of extractTitleTokens(title, language)) {
+      counts.set(token, (counts.get(token) ?? 0) + 1);
+    }
+  }
+
+  const repeated = [...counts.entries()]
+    .sort((left, right) => right[1] - left[1] || right[0].length - left[0].length || left[0].localeCompare(right[0]))
+    .find((entry) => entry[1] >= 2);
+  if (!repeated) {
+    return undefined;
+  }
+
+  const [repeatedToken, count] = repeated;
+  if (count >= 3) {
+    return { pressure: "high", repeatedToken, count, recentTitles: titles };
+  }
+  if (count >= 2 && titles.length >= 4) {
+    return { pressure: "medium", repeatedToken, count, recentTitles: titles };
+  }
+  return undefined;
+}
+
+function extractTitleTokens(title: string, language: "zh" | "en"): string[] {
+  if (language === "en") {
+    const words = title.match(/[a-z]{4,}/gi) ?? [];
+    return [...new Set(
+      words
+        .map((word) => word.toLowerCase())
+        .filter((word) => !ENGLISH_STOP_WORDS.has(word)),
+    )];
+  }
+
+  const segments = title.match(/[\u4e00-\u9fff]{2,}/g) ?? [];
+  const tokens = new Set<string>();
+  for (const segment of segments) {
+    for (let size = 2; size <= Math.min(4, segment.length); size += 1) {
+      for (let index = 0; index <= segment.length - size; index += 1) {
+        tokens.add(segment.slice(index, index + size));
+      }
+    }
+  }
+
+  return [...tokens];
+}
+
+function isMeaningfulValue(value: string): boolean {
+  const normalized = value.trim().toLowerCase();
+  if (!normalized) return false;
+  return normalized !== "none" && normalized !== "(none)" && normalized !== "无";
+}

+ 82 - 22
packages/core/src/utils/long-span-fatigue.ts

@@ -1,5 +1,6 @@
 import { readFile, readdir } from "node:fs/promises";
 import { join } from "node:path";
+import { analyzeChapterCadence } from "./chapter-cadence.js";
 
 export interface LongSpanFatigueIssue {
   readonly severity: "warning";
@@ -48,12 +49,16 @@ export async function buildEnglishVarianceBrief(params: {
   const recentRows = summaryRows
     .filter((row) => row.chapter < params.chapterNumber)
     .sort((left, right) => left.chapter - right.chapter)
-    .slice(-3);
+    .slice(-4);
 
   const highFrequencyPhrases = collectRepeatedEnglishPhrases(chapterBodies);
   const repeatedOpeningPatterns = collectRepeatedBoundaryPatterns(chapterBodies, "opening");
   const repeatedEndingShapes = collectRepeatedBoundaryPatterns(chapterBodies, "ending");
-  const sceneObligation = chooseSceneObligation(recentRows, repeatedOpeningPatterns, repeatedEndingShapes);
+  const cadence = analyzeChapterCadence({
+    rows: recentRows,
+    language: "en",
+  });
+  const sceneObligation = chooseSceneObligation(cadence, repeatedOpeningPatterns, repeatedEndingShapes);
 
   const lines = [
     "## English Variance Brief",
@@ -84,13 +89,27 @@ export async function analyzeLongSpanFatigue(
   const recentRows = mergedRows
     .filter((row) => row.chapter <= input.chapterNumber)
     .sort((left, right) => left.chapter - right.chapter)
-    .slice(-3);
+    .slice(-4);
+  const cadence = analyzeChapterCadence({
+    rows: recentRows,
+    language,
+  });
 
-  const chapterTypeIssue = buildChapterTypeIssue(recentRows, language);
+  const chapterTypeIssue = buildChapterTypeIssue(cadence, language);
   if (chapterTypeIssue) {
     issues.push(chapterTypeIssue);
   }
 
+  const moodIssue = buildMoodIssue(cadence, language);
+  if (moodIssue) {
+    issues.push(moodIssue);
+  }
+
+  const titleIssue = buildTitleIssue(cadence, language);
+  if (titleIssue) {
+    issues.push(titleIssue);
+  }
+
   const recentChapterBodies = await loadRecentChapterBodies(
     input.bookDir,
     input.chapterNumber,
@@ -181,26 +200,19 @@ function parseSummaryRow(line: string): SummaryRow | null {
 }
 
 function buildChapterTypeIssue(
-  rows: ReadonlyArray<SummaryRow>,
+  cadence: ReturnType<typeof analyzeChapterCadence>,
   language: "zh" | "en",
 ): LongSpanFatigueIssue | null {
-  if (rows.length < 3) return null;
-
-  const types = rows
-    .map((row) => row.chapterType.trim())
-    .filter((value) => isMeaningfulValue(value));
-  if (types.length < 3) return null;
-
-  const normalized = types.map((value) => value.toLowerCase());
-  if (!normalized.every((value) => value === normalized[0])) {
+  if (cadence.scenePressure?.pressure !== "high") {
     return null;
   }
+  const { repeatedType, streak } = cadence.scenePressure;
 
   if (language === "en") {
     return {
       severity: "warning",
       category: "Pacing Monotony",
-      description: `The last 3 chapter types are identical: ${types.join(" -> ")}, which suggests macro pacing monotony.`,
+      description: `The last ${streak} chapter types have stayed on ${repeatedType}, which suggests macro pacing monotony.`,
       suggestion: "Switch the next chapter's function instead of extending the same beat again. Rotate setup, payoff, reversal, and fallout more deliberately.",
     };
   }
@@ -208,11 +220,63 @@ function buildChapterTypeIssue(
   return {
     severity: "warning",
     category: "节奏单调",
-    description: `最近3章章节类型完全一致:${types.join(" -> ")},长篇节奏可能开始固化。`,
+    description: `最近${streak}章章节类型持续停留在“${repeatedType}”,长篇节奏可能开始固化。`,
     suggestion: "下一章应切换章节功能,不要连续重复同一种布局/推进节拍。",
   };
 }
 
+function buildMoodIssue(
+  cadence: ReturnType<typeof analyzeChapterCadence>,
+  language: "zh" | "en",
+): LongSpanFatigueIssue | null {
+  if (cadence.moodPressure?.pressure !== "high") {
+    return null;
+  }
+  const { highTensionStreak, recentMoods } = cadence.moodPressure;
+
+  if (language === "en") {
+    return {
+      severity: "warning",
+      category: "Mood Monotony",
+      description: `High-tension mood has locked in for ${highTensionStreak} chapters (${recentMoods.join(" -> ")}), with no visible emotional release.`,
+      suggestion: "Insert a release beat, warmth, humor, intimacy, or reflective quiet before escalating again.",
+    };
+  }
+
+  return {
+    severity: "warning",
+    category: "情绪单调",
+    description: `最近${highTensionStreak}章持续高压(${recentMoods.join(" -> ")}),缺少明显的情绪释放。`,
+    suggestion: "下一章安排一次喘息、温情、幽默或静场释放,再继续加压。",
+  };
+}
+
+function buildTitleIssue(
+  cadence: ReturnType<typeof analyzeChapterCadence>,
+  language: "zh" | "en",
+): LongSpanFatigueIssue | null {
+  if (cadence.titlePressure?.pressure !== "high") {
+    return null;
+  }
+  const { repeatedToken, count } = cadence.titlePressure;
+
+  if (language === "en") {
+    return {
+      severity: "warning",
+      category: "Title Collapse",
+      description: `Recent titles keep collapsing around "${repeatedToken}" (${count} hits in the current window), which makes chapter naming feel formulaic.`,
+      suggestion: "Change the next title anchor. Use a new image, action, consequence, or character vector instead of the same keyword shell.",
+    };
+  }
+
+  return {
+    severity: "warning",
+    category: "标题重复",
+    description: `最近标题持续围绕“${repeatedToken}”命名(当前窗口命中${count}次),命名开始坍缩。`,
+    suggestion: "下一章标题换一个新的意象、动作、后果或人物焦点,不要继续套同一个关键词壳。",
+  };
+}
+
 async function loadRecentChapterBodies(
   bookDir: string,
   currentChapter: number,
@@ -352,15 +416,11 @@ function collectRepeatedBoundaryPatterns(
 }
 
 function chooseSceneObligation(
-  rows: ReadonlyArray<SummaryRow>,
+  cadence: ReturnType<typeof analyzeChapterCadence>,
   repeatedOpenings: ReadonlyArray<string>,
   repeatedEndings: ReadonlyArray<string>,
 ): string {
-  const recentTypes = rows
-    .map((row) => row.chapterType.trim().toLowerCase())
-    .filter((type) => type.length > 0);
-
-  if (recentTypes.length >= 3 && recentTypes.every((type) => type === recentTypes[0])) {
+  if (cadence.scenePressure?.pressure === "high") {
     return "confrontation under pressure";
   }
   if (repeatedEndings.length > 0) {