Przeglądaj źródła

fix: add short paragraph and density drift detection

- Detect consecutive short paragraphs (< 80 chars)
- Detect paragraph fragmentation (> 40% short paragraphs)
- Track paragraph shape across chapters for drift detection
- Add warnings to post-write validator output

Closes #90
Ma 7 miesięcy temu
rodzic
commit
bfdd7a4ac7

+ 60 - 1
packages/core/src/__tests__/post-write-validator.test.ts

@@ -1,5 +1,9 @@
 import { describe, it, expect } from "vitest";
-import { validatePostWrite, type PostWriteViolation } from "../agents/post-write-validator.js";
+import {
+  detectParagraphLengthDrift,
+  validatePostWrite,
+  type PostWriteViolation,
+} from "../agents/post-write-validator.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 
 const baseProfile: GenreProfile = {
@@ -123,6 +127,35 @@ describe("validatePostWrite", () => {
     expect(findRule(result, "段落过长")).toBeDefined();
   });
 
+  it("detects fragmented short paragraphs in Chinese prose", () => {
+    const content = [
+      "门开了。",
+      "他没进去。",
+      "先听了一下。",
+      "里面没有声响。",
+      "他才把手按上去。",
+      "冷意顺着门缝钻出来。",
+    ].join("\n\n");
+
+    const result = validatePostWrite(content, baseProfile, null);
+    expect(findRule(result, "段落过碎")).toBeDefined();
+    expect(findRule(result, "段落过碎")?.severity).toBe("warning");
+  });
+
+  it("detects runs of consecutive short paragraphs", () => {
+    const content = [
+      "他绕过柜台,把灯挪到门边,先看了一眼地上的水印,确认脚印是新的。",
+      "门虚掩着。",
+      "风从外面钻进来。",
+      "他没有立刻追出去。",
+      "他先低头,看见门槛上沾了一点灰黑色的泥。",
+    ].join("\n\n");
+
+    const result = validatePostWrite(content, baseProfile, null);
+    expect(findRule(result, "连续短段")).toBeDefined();
+    expect(findRule(result, "连续短段")?.severity).toBe("warning");
+  });
+
   it("detects book-level prohibitions", () => {
     const bookRules = {
       version: "1",
@@ -158,4 +191,30 @@ describe("validatePostWrite", () => {
     expect(findRule(result, "Dialogue pressure")).toBeDefined();
     expect(findRule(result, "Dialogue pressure")?.severity).toBe("warning");
   });
+
+  it("detects paragraph density drift against recent chapters", () => {
+    const recent = [
+      "他把伞挂在门边,又低头看了一眼鞋底带进来的泥。柜台后的热水壶正轻轻作响,白气沿着玻璃慢慢爬上去。林越没有急着开口,只先把屋里的灯都扫了一遍,确认少了一盏。",
+      "",
+      "姜敏把账本推过来时,手指还压在封皮边上,没有立刻松开。她先问他是不是又去找过旧港的人,然后才把下午听到的消息一点点拆开说,连谁在门口停过脚都没漏掉。",
+      "",
+      "---",
+      "",
+      "他靠着墙站了半分钟,才把那张折过三次的纸重新摊开。纸上的字不多,但每一行都像故意留了半截,逼着他把前后几天听到的话重新拼回去。",
+      "",
+      "外面的雨势已经压下来,棚顶被打得一阵紧一阵。林越没有马上下楼,而是先把窗推开一条缝,让冷风吹进来,把刚才在屋里积住的闷气慢慢散掉。",
+    ].join("\n\n");
+    const current = [
+      "他停下。",
+      "先看门。",
+      "又看窗。",
+      "没人说话。",
+      "他这才进去。",
+      "屋里很冷。",
+    ].join("\n\n");
+
+    const result = detectParagraphLengthDrift(current, recent, "zh");
+    expect(findRule(result, "段落密度漂移")).toBeDefined();
+    expect(findRule(result, "段落密度漂移")?.severity).toBe("warning");
+  });
 });

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

@@ -15,6 +15,15 @@ export interface PostWriteViolation {
   readonly suggestion: string;
 }
 
+interface ParagraphShape {
+  readonly paragraphs: ReadonlyArray<string>;
+  readonly shortThreshold: number;
+  readonly shortParagraphs: ReadonlyArray<string>;
+  readonly shortRatio: number;
+  readonly averageLength: number;
+  readonly maxConsecutiveShort: number;
+}
+
 // --- Marker word lists ---
 
 /** AI转折/惊讶标记词 */
@@ -226,6 +235,8 @@ export function validatePostWrite(
     });
   }
 
+  appendParagraphShapeWarnings(violations, content, "zh");
+
   // 11. Book-level prohibitions
   // Short prohibitions (2-30 chars): exact substring match
   // Long prohibitions (>30 chars): skip — these are conceptual rules for prompt-level enforcement only
@@ -313,6 +324,45 @@ export function detectCrossChapterRepetition(
   return violations;
 }
 
+export function detectParagraphLengthDrift(
+  currentContent: string,
+  recentChaptersContent: string,
+  language: "zh" | "en" = "zh",
+): ReadonlyArray<PostWriteViolation> {
+  if (!recentChaptersContent || recentChaptersContent.trim().length === 0) return [];
+
+  const current = analyzeParagraphShape(currentContent, language);
+  const recent = analyzeParagraphShape(recentChaptersContent, language);
+
+  if (current.paragraphs.length < 4 || recent.paragraphs.length < 4) return [];
+  if (recent.averageLength <= 0 || current.averageLength <= 0) return [];
+
+  const shrinkRatio = current.averageLength / recent.averageLength;
+  const shortRatioDelta = current.shortRatio - recent.shortRatio;
+
+  if (shrinkRatio >= 0.6 || current.shortRatio < 0.5 || shortRatioDelta < 0.25) {
+    return [];
+  }
+
+  const dropPercent = Math.round((1 - shrinkRatio) * 100);
+
+  return [
+    language === "en"
+      ? {
+          rule: "Paragraph density drift",
+          severity: "warning",
+          description: `Average paragraph length dropped from ${Math.round(recent.averageLength)} to ${Math.round(current.averageLength)} characters (${dropPercent}% shorter) compared with recent chapters.`,
+          suggestion: "Let action, observation, and reaction share paragraphs more often instead of cutting every beat into a single short line.",
+        }
+      : {
+          rule: "段落密度漂移",
+          severity: "warning",
+          description: `当前章平均段长从近期章节的${Math.round(recent.averageLength)}字降到${Math.round(current.averageLength)}字,缩短了${dropPercent}%。`,
+          suggestion: "不要把每个动作都切成单独短句;适当把动作、观察和反应并入同一段,恢复段落层次。",
+        },
+  ];
+}
+
 /** English-specific post-write validation rules. */
 function validatePostWriteEnglish(
   content: string,
@@ -348,6 +398,8 @@ function validatePostWriteEnglish(
     });
   }
 
+  appendParagraphShapeWarnings(violations, content, "en");
+
   // 2.5. Multi-character scene with almost no direct exchange
   const quotedLines = content.match(/"[^"]+"/g) ?? [];
   const englishNames = [...new Set(
@@ -397,6 +449,89 @@ function validatePostWriteEnglish(
   return violations;
 }
 
+function appendParagraphShapeWarnings(
+  violations: PostWriteViolation[],
+  content: string,
+  language: "zh" | "en",
+): void {
+  const shape = analyzeParagraphShape(content, language);
+  if (shape.paragraphs.length < 4) return;
+
+  if (shape.shortParagraphs.length >= 4 && shape.shortRatio >= 0.6) {
+    violations.push(
+      language === "en"
+        ? {
+            rule: "Paragraph fragmentation",
+            severity: "warning",
+            description: `${shape.shortParagraphs.length} of ${shape.paragraphs.length} paragraphs are shorter than ${shape.shortThreshold} characters.`,
+            suggestion: "Merge adjacent action, observation, and reaction beats so the chapter does not collapse into one-line paragraphs.",
+          }
+        : {
+            rule: "段落过碎",
+            severity: "warning",
+            description: `${shape.paragraphs.length}个段落里有${shape.shortParagraphs.length}个不足${shape.shortThreshold}字,段落被切得过碎。`,
+            suggestion: "把相邻的动作、观察、反应适当并段,不要每句话都单独起段。",
+          },
+    );
+  }
+
+  if (shape.maxConsecutiveShort >= 3) {
+    violations.push(
+      language === "en"
+        ? {
+            rule: "Consecutive short paragraphs",
+            severity: "warning",
+            description: `${shape.maxConsecutiveShort} short paragraphs appear back to back.`,
+            suggestion: "Break the one-beat-per-paragraph rhythm by folding connected beats into fuller paragraphs.",
+          }
+        : {
+            rule: "连续短段",
+            severity: "warning",
+            description: `连续出现${shape.maxConsecutiveShort}个不足${shape.shortThreshold}字的短段,容易形成短句堆砌。`,
+            suggestion: "把连续的碎动作重新编组,至少让一个段落承载完整的动作链或情绪推进。",
+          },
+    );
+  }
+}
+
+function analyzeParagraphShape(content: string, language: "zh" | "en"): ParagraphShape {
+  const paragraphs = extractParagraphs(content);
+  const shortThreshold = language === "en" ? 120 : 35;
+  const shortParagraphs = paragraphs.filter((paragraph) => paragraph.length < shortThreshold);
+  const averageLength = paragraphs.length > 0
+    ? paragraphs.reduce((sum, paragraph) => sum + paragraph.length, 0) / paragraphs.length
+    : 0;
+
+  let maxConsecutiveShort = 0;
+  let currentConsecutive = 0;
+  for (const paragraph of paragraphs) {
+    if (paragraph.length < shortThreshold) {
+      currentConsecutive++;
+      maxConsecutiveShort = Math.max(maxConsecutiveShort, currentConsecutive);
+    } else {
+      currentConsecutive = 0;
+    }
+  }
+
+  return {
+    paragraphs,
+    shortThreshold,
+    shortParagraphs,
+    shortRatio: paragraphs.length > 0 ? shortParagraphs.length / paragraphs.length : 0,
+    averageLength,
+    maxConsecutiveShort,
+  };
+}
+
+function extractParagraphs(content: string): string[] {
+  return content
+    .split(/\n\s*\n/)
+    .map((paragraph) => paragraph.trim())
+    .filter((paragraph) => paragraph.length > 0)
+    .filter((paragraph) => paragraph !== "---")
+    .filter((paragraph) => !paragraph.startsWith("#"));
+}
+
 const ENGLISH_NAME_STOP_WORDS = new Set([
   "The",
   "And",

+ 7 - 1
packages/core/src/agents/writer.ts

@@ -8,7 +8,12 @@ import { buildObserverSystemPrompt, buildObserverUserPrompt } from "./observer-p
 import { parseSettlerDeltaOutput } from "./settler-delta-parser.js";
 import { parseSettlementOutput } from "./settler-parser.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
-import { validatePostWrite, detectCrossChapterRepetition, type PostWriteViolation } from "./post-write-validator.js";
+import {
+  detectCrossChapterRepetition,
+  detectParagraphLengthDrift,
+  validatePostWrite,
+  type PostWriteViolation,
+} from "./post-write-validator.js";
 import { analyzeAITells } from "./ai-tells.js";
 import type { ChapterTrace, ContextPackage, RuleStack } from "../models/input-governance.js";
 import type { LengthSpec } from "../models/length-governance.js";
@@ -324,6 +329,7 @@ export class WriterAgent extends BaseAgent {
     const ruleViolations = [
       ...validatePostWrite(creative.content, genreProfile, bookRules, resolvedLanguage),
       ...detectCrossChapterRepetition(creative.content, fingerprintChapters, resolvedLanguage),
+      ...detectParagraphLengthDrift(creative.content, fingerprintChapters, resolvedLanguage),
     ];
     const aiTellIssues = analyzeAITells(creative.content).issues;