Przeglądaj źródła

refactor(pipeline): centralize cadence pressure policy

Ma 7 miesięcy temu
rodzic
commit
71a860e7d5

+ 44 - 0
packages/core/src/__tests__/cadence-policy.test.ts

@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import {
+  CADENCE_PRESSURE_THRESHOLDS,
+  CADENCE_WINDOW_DEFAULTS,
+  LONG_SPAN_FATIGUE_THRESHOLDS,
+  resolveCadencePressure,
+} from "../utils/cadence-policy.js";
+
+describe("cadence-policy", () => {
+  it("exposes shared cadence and fatigue defaults from one policy surface", () => {
+    expect(CADENCE_WINDOW_DEFAULTS.summaryLookback).toBe(4);
+    expect(CADENCE_WINDOW_DEFAULTS.englishVarianceLookback).toBeGreaterThan(
+      CADENCE_WINDOW_DEFAULTS.summaryLookback,
+    );
+    expect(LONG_SPAN_FATIGUE_THRESHOLDS.boundarySimilarityFloor).toBe(0.72);
+    expect(LONG_SPAN_FATIGUE_THRESHOLDS.boundarySentenceMinLength).toBe(18);
+  });
+
+  it("resolves shared medium/high cadence pressure without duplicating threshold logic", () => {
+    expect(resolveCadencePressure({
+      count: 3,
+      total: 4,
+      highThreshold: CADENCE_PRESSURE_THRESHOLDS.scene.highCount,
+      mediumThreshold: CADENCE_PRESSURE_THRESHOLDS.scene.mediumCount,
+      mediumWindowFloor: CADENCE_PRESSURE_THRESHOLDS.scene.mediumWindowFloor,
+    })).toBe("high");
+
+    expect(resolveCadencePressure({
+      count: 2,
+      total: 4,
+      highThreshold: CADENCE_PRESSURE_THRESHOLDS.mood.highCount,
+      mediumThreshold: CADENCE_PRESSURE_THRESHOLDS.mood.mediumCount,
+      mediumWindowFloor: CADENCE_PRESSURE_THRESHOLDS.mood.mediumWindowFloor,
+    })).toBe("medium");
+
+    expect(resolveCadencePressure({
+      count: 1,
+      total: 3,
+      highThreshold: CADENCE_PRESSURE_THRESHOLDS.title.highCount,
+      mediumThreshold: CADENCE_PRESSURE_THRESHOLDS.title.mediumCount,
+      mediumWindowFloor: CADENCE_PRESSURE_THRESHOLDS.title.mediumWindowFloor,
+    })).toBeUndefined();
+  });
+});

+ 46 - 0
packages/core/src/utils/cadence-policy.ts

@@ -0,0 +1,46 @@
+export const CADENCE_WINDOW_DEFAULTS = {
+  summaryLookback: 4,
+  englishVarianceLookback: 24,
+  recentBoundaryPatternBodies: 2,
+} as const;
+
+export const CADENCE_PRESSURE_THRESHOLDS = {
+  scene: {
+    highCount: 3,
+    mediumCount: 2,
+    mediumWindowFloor: 4,
+  },
+  mood: {
+    highCount: 3,
+    mediumCount: 2,
+    mediumWindowFloor: 4,
+  },
+  title: {
+    minimumRepeatedCount: 2,
+    highCount: 3,
+    mediumCount: 2,
+    mediumWindowFloor: 4,
+  },
+} as const;
+
+export const LONG_SPAN_FATIGUE_THRESHOLDS = {
+  boundarySimilarityFloor: 0.72,
+  boundarySentenceMinLength: 18,
+  boundaryPatternMinBodies: 3,
+} as const;
+
+export function resolveCadencePressure(params: {
+  readonly count: number;
+  readonly total: number;
+  readonly highThreshold: number;
+  readonly mediumThreshold: number;
+  readonly mediumWindowFloor: number;
+}): "medium" | "high" | undefined {
+  if (params.count >= params.highThreshold) {
+    return "high";
+  }
+  if (params.count >= params.mediumThreshold && params.total >= params.mediumWindowFloor) {
+    return "medium";
+  }
+  return undefined;
+}

+ 36 - 18
packages/core/src/utils/chapter-cadence.ts

@@ -1,3 +1,9 @@
+import {
+  CADENCE_PRESSURE_THRESHOLDS,
+  CADENCE_WINDOW_DEFAULTS,
+  resolveCadencePressure,
+} from "./cadence-policy.js";
+
 export interface CadenceSummaryRow {
 export interface CadenceSummaryRow {
   readonly chapter: number;
   readonly chapter: number;
   readonly title: string;
   readonly title: string;
@@ -30,7 +36,7 @@ export interface ChapterCadenceAnalysis {
   readonly titlePressure?: TitleCadencePressure;
   readonly titlePressure?: TitleCadencePressure;
 }
 }
 
 
-export const DEFAULT_CHAPTER_CADENCE_WINDOW = 4;
+export const DEFAULT_CHAPTER_CADENCE_WINDOW = CADENCE_WINDOW_DEFAULTS.summaryLookback;
 
 
 const HIGH_TENSION_KEYWORDS = [
 const HIGH_TENSION_KEYWORDS = [
   "紧张", "冷硬", "压抑", "逼仄", "肃杀", "沉重", "凝重",
   "紧张", "冷硬", "压抑", "逼仄", "肃杀", "沉重", "凝重",
@@ -51,7 +57,7 @@ export function analyzeChapterCadence(params: {
 }): ChapterCadenceAnalysis {
 }): ChapterCadenceAnalysis {
   const recentRows = [...params.rows]
   const recentRows = [...params.rows]
     .sort((left, right) => left.chapter - right.chapter)
     .sort((left, right) => left.chapter - right.chapter)
-    .slice(-DEFAULT_CHAPTER_CADENCE_WINDOW);
+    .slice(-CADENCE_WINDOW_DEFAULTS.summaryLookback);
 
 
   return {
   return {
     scenePressure: analyzeScenePressure(recentRows),
     scenePressure: analyzeScenePressure(recentRows),
@@ -88,11 +94,15 @@ function analyzeScenePressure(
     streak += 1;
     streak += 1;
   }
   }
 
 
-  if (streak >= 3) {
-    return { pressure: "high", repeatedType, streak };
-  }
-  if (streak >= 2 && types.length >= 4) {
-    return { pressure: "medium", repeatedType, streak };
+  const pressure = resolveCadencePressure({
+    count: streak,
+    total: types.length,
+    highThreshold: CADENCE_PRESSURE_THRESHOLDS.scene.highCount,
+    mediumThreshold: CADENCE_PRESSURE_THRESHOLDS.scene.mediumCount,
+    mediumWindowFloor: CADENCE_PRESSURE_THRESHOLDS.scene.mediumWindowFloor,
+  });
+  if (pressure) {
+    return { pressure, repeatedType, streak };
   }
   }
   return undefined;
   return undefined;
 }
 }
@@ -117,11 +127,15 @@ function analyzeMoodPressure(
     highTensionStreak += 1;
     highTensionStreak += 1;
   }
   }
 
 
-  if (highTensionStreak >= 3) {
-    return { pressure: "high", highTensionStreak, recentMoods };
-  }
-  if (highTensionStreak >= 2 && moods.length >= 4) {
-    return { pressure: "medium", highTensionStreak, recentMoods };
+  const pressure = resolveCadencePressure({
+    count: highTensionStreak,
+    total: moods.length,
+    highThreshold: CADENCE_PRESSURE_THRESHOLDS.mood.highCount,
+    mediumThreshold: CADENCE_PRESSURE_THRESHOLDS.mood.mediumCount,
+    mediumWindowFloor: CADENCE_PRESSURE_THRESHOLDS.mood.mediumWindowFloor,
+  });
+  if (pressure) {
+    return { pressure, highTensionStreak, recentMoods };
   }
   }
   return undefined;
   return undefined;
 }
 }
@@ -146,17 +160,21 @@ function analyzeTitlePressure(
 
 
   const repeated = [...counts.entries()]
   const repeated = [...counts.entries()]
     .sort((left, right) => right[1] - left[1] || right[0].length - left[0].length || left[0].localeCompare(right[0]))
     .sort((left, right) => right[1] - left[1] || right[0].length - left[0].length || left[0].localeCompare(right[0]))
-    .find((entry) => entry[1] >= 2);
+    .find((entry) => entry[1] >= CADENCE_PRESSURE_THRESHOLDS.title.minimumRepeatedCount);
   if (!repeated) {
   if (!repeated) {
     return undefined;
     return undefined;
   }
   }
 
 
   const [repeatedToken, count] = repeated;
   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 };
+  const pressure = resolveCadencePressure({
+    count,
+    total: titles.length,
+    highThreshold: CADENCE_PRESSURE_THRESHOLDS.title.highCount,
+    mediumThreshold: CADENCE_PRESSURE_THRESHOLDS.title.mediumCount,
+    mediumWindowFloor: CADENCE_PRESSURE_THRESHOLDS.title.mediumWindowFloor,
+  });
+  if (pressure) {
+    return { pressure, repeatedToken, count, recentTitles: titles };
   }
   }
   return undefined;
   return undefined;
 }
 }

+ 16 - 9
packages/core/src/utils/long-span-fatigue.ts

@@ -1,6 +1,10 @@
 import { readFile, readdir } from "node:fs/promises";
 import { readFile, readdir } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 import { analyzeChapterCadence } from "./chapter-cadence.js";
 import { analyzeChapterCadence } from "./chapter-cadence.js";
+import {
+  CADENCE_WINDOW_DEFAULTS,
+  LONG_SPAN_FATIGUE_THRESHOLDS,
+} from "./cadence-policy.js";
 
 
 export interface LongSpanFatigueIssue {
 export interface LongSpanFatigueIssue {
   readonly severity: "warning";
   readonly severity: "warning";
@@ -32,7 +36,6 @@ interface SummaryRow {
   readonly chapterType: string;
   readonly chapterType: string;
 }
 }
 
 
-const SENTENCE_SIMILARITY_THRESHOLD = 0.72;
 const CHINESE_PUNCTUATION = /[,。!?;:“”‘’()《》、\s\-—…·]/g;
 const CHINESE_PUNCTUATION = /[,。!?;:“”‘’()《》、\s\-—…·]/g;
 const ENGLISH_PUNCTUATION = /[^a-z0-9]+/gi;
 const ENGLISH_PUNCTUATION = /[^a-z0-9]+/gi;
 
 
@@ -40,7 +43,11 @@ export async function buildEnglishVarianceBrief(params: {
   readonly bookDir: string;
   readonly bookDir: string;
   readonly chapterNumber: number;
   readonly chapterNumber: number;
 }): Promise<EnglishVarianceBrief | null> {
 }): Promise<EnglishVarianceBrief | null> {
-  const chapterBodies = await loadPreviousChapterBodies(params.bookDir, params.chapterNumber, 24);
+  const chapterBodies = await loadPreviousChapterBodies(
+    params.bookDir,
+    params.chapterNumber,
+    CADENCE_WINDOW_DEFAULTS.englishVarianceLookback,
+  );
   if (chapterBodies.length < 2) {
   if (chapterBodies.length < 2) {
     return null;
     return null;
   }
   }
@@ -49,7 +56,7 @@ export async function buildEnglishVarianceBrief(params: {
   const recentRows = summaryRows
   const recentRows = summaryRows
     .filter((row) => row.chapter < params.chapterNumber)
     .filter((row) => row.chapter < params.chapterNumber)
     .sort((left, right) => left.chapter - right.chapter)
     .sort((left, right) => left.chapter - right.chapter)
-    .slice(-4);
+    .slice(-CADENCE_WINDOW_DEFAULTS.summaryLookback);
 
 
   const highFrequencyPhrases = collectRepeatedEnglishPhrases(chapterBodies);
   const highFrequencyPhrases = collectRepeatedEnglishPhrases(chapterBodies);
   const repeatedOpeningPatterns = collectRepeatedBoundaryPatterns(chapterBodies, "opening");
   const repeatedOpeningPatterns = collectRepeatedBoundaryPatterns(chapterBodies, "opening");
@@ -89,7 +96,7 @@ export async function analyzeLongSpanFatigue(
   const recentRows = mergedRows
   const recentRows = mergedRows
     .filter((row) => row.chapter <= input.chapterNumber)
     .filter((row) => row.chapter <= input.chapterNumber)
     .sort((left, right) => left.chapter - right.chapter)
     .sort((left, right) => left.chapter - right.chapter)
-    .slice(-4);
+    .slice(-CADENCE_WINDOW_DEFAULTS.summaryLookback);
   const cadence = analyzeChapterCadence({
   const cadence = analyzeChapterCadence({
     rows: recentRows,
     rows: recentRows,
     language,
     language,
@@ -289,9 +296,9 @@ async function loadRecentChapterBodies(
       .map((file) => ({ file, chapter: Number.parseInt(file.slice(0, 4), 10) }))
       .map((file) => ({ file, chapter: Number.parseInt(file.slice(0, 4), 10) }))
       .filter((entry) => Number.isFinite(entry.chapter) && entry.chapter < currentChapter && entry.file.endsWith(".md"))
       .filter((entry) => Number.isFinite(entry.chapter) && entry.chapter < currentChapter && entry.file.endsWith(".md"))
       .sort((left, right) => left.chapter - right.chapter)
       .sort((left, right) => left.chapter - right.chapter)
-      .slice(-2);
+      .slice(-CADENCE_WINDOW_DEFAULTS.recentBoundaryPatternBodies);
 
 
-    if (previousFiles.length < 2) {
+    if (previousFiles.length < CADENCE_WINDOW_DEFAULTS.recentBoundaryPatternBodies) {
       return [];
       return [];
     }
     }
 
 
@@ -310,7 +317,7 @@ function buildSentencePatternIssue(
   boundary: "opening" | "ending",
   boundary: "opening" | "ending",
   language: "zh" | "en",
   language: "zh" | "en",
 ): LongSpanFatigueIssue | null {
 ): LongSpanFatigueIssue | null {
-  if (chapterBodies.length < 3) return null;
+  if (chapterBodies.length < LONG_SPAN_FATIGUE_THRESHOLDS.boundaryPatternMinBodies) return null;
 
 
   const sentences = chapterBodies.map((body) => extractBoundarySentence(body, boundary));
   const sentences = chapterBodies.map((body) => extractBoundarySentence(body, boundary));
   if (sentences.some((sentence) => sentence === null)) {
   if (sentences.some((sentence) => sentence === null)) {
@@ -319,7 +326,7 @@ function buildSentencePatternIssue(
 
 
   const normalized = sentences
   const normalized = sentences
     .map((sentence) => normalizeSentence(sentence!, language));
     .map((sentence) => normalizeSentence(sentence!, language));
-  if (normalized.some((sentence) => sentence.length < 18)) {
+  if (normalized.some((sentence) => sentence.length < LONG_SPAN_FATIGUE_THRESHOLDS.boundarySentenceMinLength)) {
     return null;
     return null;
   }
   }
 
 
@@ -327,7 +334,7 @@ function buildSentencePatternIssue(
     diceCoefficient(normalized[0]!, normalized[1]!),
     diceCoefficient(normalized[0]!, normalized[1]!),
     diceCoefficient(normalized[1]!, normalized[2]!),
     diceCoefficient(normalized[1]!, normalized[2]!),
   ];
   ];
-  if (Math.min(...similarities) < SENTENCE_SIMILARITY_THRESHOLD) {
+  if (Math.min(...similarities) < LONG_SPAN_FATIGUE_THRESHOLDS.boundarySimilarityFloor) {
     return null;
     return null;
   }
   }