Răsfoiți Sursa

feat: add governed working set, long-span fatigue, and spot-fix patches

- governed-working-set: filter truth files for settler/reviser context
- long-span-fatigue: detect cross-chapter lexical fatigue patterns
- spot-fix-patches: structured patch application for spot-fix revisions
Ma 7 luni în urmă
părinte
comite
8992c0a3f3

+ 52 - 0
packages/core/src/__tests__/governed-working-set.test.ts

@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { buildGovernedCharacterMatrixWorkingSet } from "../utils/governed-working-set.js";
+
+describe("governed-working-set", () => {
+  it("filters character matrix by exact governed character mentions instead of broad capitalized tokens", () => {
+    const matrix = [
+      "# Character Matrix",
+      "",
+      "### Character Profiles",
+      "| Character | Core Tags | Contrast Detail | Speech Style | Personality Core | Relationship to Protagonist | Core Motivation | Current Goal |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| Lin Yue | oath | restraint | clipped | stubborn | self | repay debt | find mentor |",
+      "| Guildmaster Ren | guild | swagger | loud | opportunistic | rival | stall Mara | seize seal |",
+      "",
+      "### Encounter Log",
+      "| Character A | Character B | First Meeting Chapter | Latest Interaction Chapter | Relationship Type | Relationship Change |",
+      "| --- | --- | --- | --- | --- | --- |",
+      "| Lin Yue | Guildmaster Ren | 1 | 5 | rivalry | strained |",
+      "",
+      "### Information Boundaries",
+      "| Character | Known Information | Unknown Information | Source Chapter |",
+      "| --- | --- | --- | --- |",
+      "| Lin Yue | Mentor left without explanation | Why the oath was broken | 99 |",
+      "| Guildmaster Ren | Harbor roster | Mentor oath debt | 12 |",
+    ].join("\n");
+
+    const filtered = buildGovernedCharacterMatrixWorkingSet({
+      matrixMarkdown: matrix,
+      chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
+      contextPackage: {
+        chapter: 100,
+        selectedContext: [
+          {
+            source: "story/chapter_summaries.md#99",
+            reason: "Relevant episodic memory.",
+            excerpt: "Locked Gate | Lin Yue chooses the mentor line over the guild line | mentor-oath advanced",
+          },
+          {
+            source: "story/pending_hooks.md#mentor-oath",
+            reason: "Carry forward unresolved hook.",
+            excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
+          },
+        ],
+      },
+    });
+
+    expect(filtered).toContain("| Lin Yue | oath | restraint | clipped | stubborn | self | repay debt | find mentor |");
+    expect(filtered).not.toContain("| Guildmaster Ren | guild | swagger | loud | opportunistic | rival | stall Mara | seize seal |");
+    expect(filtered).not.toContain("| Lin Yue | Guildmaster Ren | 1 | 5 | rivalry | strained |");
+    expect(filtered).not.toContain("| Guildmaster Ren | Harbor roster | Mentor oath debt | 12 |");
+  });
+});

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

@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js";
+
+async function createBookDir(prefix: string): Promise<string> {
+  const root = await mkdtemp(join(tmpdir(), prefix));
+  const bookDir = join(root, "book");
+  await mkdir(join(bookDir, "story"), { recursive: true });
+  await mkdir(join(bookDir, "chapters"), { recursive: true });
+  return bookDir;
+}
+
+async function writeChapter(bookDir: string, chapter: number, title: string, body: string): Promise<void> {
+  const filename = `${String(chapter).padStart(4, "0")}_${title}.md`;
+  await writeFile(
+    join(bookDir, "chapters", filename),
+    `# 第${chapter}章 ${title}\n\n${body}\n`,
+    "utf-8",
+  );
+}
+
+describe("analyzeLongSpanFatigue", () => {
+  it("warns when the last three chapter types are identical", async () => {
+    const bookDir = await createBookDir("inkos-long-span-type-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);
+    } finally {
+      await rm(join(bookDir, ".."), { recursive: true, force: true });
+    }
+  });
+
+  it("warns in English when recent chapter endings are highly similar", async () => {
+    const bookDir = await createBookDir("inkos-long-span-ending-test-");
+
+    await Promise.all([
+      writeChapter(bookDir, 1, "Debt", "The rain had finally stopped. The harbor lights thinned behind him. He knew the debt had only grown heavier."),
+      writeChapter(bookDir, 2, "Weight", "Morning fog crawled over the quay. No one called his name. He knew the debt had only grown heavier tonight."),
+    ]);
+
+    try {
+      const result = await analyzeLongSpanFatigue({
+        bookDir,
+        chapterNumber: 3,
+        chapterContent: "The alley was empty by the time he turned back. Even the dogs had gone quiet. He knew the debt had only grown heavier again.",
+        language: "en",
+      });
+
+      expect(result.issues.some((issue) => issue.category === "Ending Pattern Repetition")).toBe(true);
+      expect(result.issues.some((issue) => issue.description.includes("last 3 chapter endings"))).toBe(true);
+    } finally {
+      await rm(join(bookDir, ".."), { recursive: true, force: true });
+    }
+  });
+});

+ 98 - 0
packages/core/src/__tests__/spot-fix-patches.test.ts

@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+import {
+  applySpotFixPatches,
+  parseSpotFixPatches,
+  type SpotFixPatch,
+} from "../utils/spot-fix-patches.js";
+
+describe("spot-fix patches", () => {
+  it("parses patch blocks from the PATCHES section", () => {
+    const patches = parseSpotFixPatches([
+      "=== PATCHES ===",
+      "--- PATCH 1 ---",
+      "TARGET_TEXT:",
+      "原句一。",
+      "REPLACEMENT_TEXT:",
+      "新句一。",
+      "--- END PATCH ---",
+      "--- PATCH 2 ---",
+      "TARGET_TEXT:",
+      "原句二。",
+      "REPLACEMENT_TEXT:",
+      "新句二。",
+      "--- END PATCH ---",
+    ].join("\n"));
+
+    expect(patches).toEqual<SpotFixPatch[]>([
+      { targetText: "原句一。", replacementText: "新句一。" },
+      { targetText: "原句二。", replacementText: "新句二。" },
+    ]);
+  });
+
+  it("applies a uniquely targeted patch while preserving untouched text", () => {
+    const original = [
+      "门轴轻轻响了一下。",
+      "林越没有立刻进去。",
+      "",
+      "巷子尽头的风还在吹。",
+      "他把手按在潮冷的门框上,没有出声。",
+      "更远处传来极轻的脚步回响,又很快断掉。",
+    ].join("\n");
+
+    const result = applySpotFixPatches(original, [
+      {
+        targetText: "林越没有立刻进去。",
+        replacementText: "林越先停在门槛外,侧耳听了一息。",
+      },
+    ]);
+
+    expect(result.applied).toBe(true);
+    expect(result.revisedContent).toBe([
+      "门轴轻轻响了一下。",
+      "林越先停在门槛外,侧耳听了一息。",
+      "",
+      "巷子尽头的风还在吹。",
+      "他把手按在潮冷的门框上,没有出声。",
+      "更远处传来极轻的脚步回响,又很快断掉。",
+    ].join("\n"));
+  });
+
+  it("rejects patches whose target text is not unique", () => {
+    const original = "他停了一下。\n门里的人也停了一下。";
+
+    const result = applySpotFixPatches(original, [
+      {
+        targetText: "停了一下",
+        replacementText: "顿了顿",
+      },
+    ]);
+
+    expect(result.applied).toBe(false);
+    expect(result.revisedContent).toBe(original);
+    expect(result.rejectedReason).toContain("exactly once");
+  });
+
+  it("rejects oversized patch sets that touch too much of the chapter", () => {
+    const original = [
+      "第一段很长,需要保留原样。",
+      "第二段也很长,需要保留原样。",
+      "第三段也很长,需要保留原样。",
+      "第四段也很长,需要保留原样。",
+    ].join("\n");
+
+    const result = applySpotFixPatches(original, [
+      {
+        targetText: [
+          "第一段很长,需要保留原样。",
+          "第二段也很长,需要保留原样。",
+          "第三段也很长,需要保留原样。",
+        ].join("\n"),
+        replacementText: "这里被大段重写了。",
+      },
+    ]);
+
+    expect(result.applied).toBe(false);
+    expect(result.revisedContent).toBe(original);
+    expect(result.rejectedReason).toContain("touch");
+  });
+});

+ 334 - 0
packages/core/src/utils/governed-working-set.ts

@@ -0,0 +1,334 @@
+import type { ContextPackage } from "../models/input-governance.js";
+import { parsePendingHooksMarkdown, renderHookSnapshot } from "./memory-retrieval.js";
+
+export function buildGovernedHookWorkingSet(params: {
+  readonly hooksMarkdown: string;
+  readonly contextPackage: ContextPackage;
+  readonly chapterNumber: number;
+  readonly language: "zh" | "en";
+  readonly keepRecent?: number;
+}): string {
+  const { hooksMarkdown } = params;
+  if (!hooksMarkdown || hooksMarkdown === "(文件不存在)" || hooksMarkdown === "(文件尚未创建)") {
+    return hooksMarkdown;
+  }
+
+  const hooks = parsePendingHooksMarkdown(hooksMarkdown);
+  if (hooks.length === 0) {
+    return hooksMarkdown;
+  }
+
+  const selectedIds = new Set(
+    params.contextPackage.selectedContext
+      .filter((entry) => entry.source.startsWith("story/pending_hooks.md#"))
+      .map((entry) => entry.source.slice("story/pending_hooks.md#".length))
+      .filter(Boolean),
+  );
+  const recentCutoff = Math.max(0, params.chapterNumber - (params.keepRecent ?? 5));
+  const workingSet = hooks.filter((hook) =>
+    selectedIds.has(hook.hookId)
+      || hook.lastAdvancedChapter >= recentCutoff
+      || hook.startChapter >= recentCutoff,
+  );
+
+  if (workingSet.length === 0 || workingSet.length >= hooks.length) {
+    return hooksMarkdown;
+  }
+
+  return renderHookSnapshot(workingSet, params.language);
+}
+
+export function mergeTableMarkdownByKey(
+  original: string,
+  updated: string,
+  keyColumns: ReadonlyArray<number>,
+): string {
+  const originalTable = parseSingleTable(original);
+  const updatedTable = parseSingleTable(updated);
+  if (!originalTable || !updatedTable || updatedTable.dataRows.length === 0) {
+    return updated;
+  }
+
+  const mergedRows = [...originalTable.dataRows];
+  const originalIndex = new Map<string, number>();
+  mergedRows.forEach((row, index) => {
+    originalIndex.set(buildKey(row, keyColumns), index);
+  });
+
+  for (const row of updatedTable.dataRows) {
+    const key = buildKey(row, keyColumns);
+    const existing = originalIndex.get(key);
+    if (existing === undefined) {
+      originalIndex.set(key, mergedRows.length);
+      mergedRows.push(row);
+    } else {
+      mergedRows[existing] = row;
+    }
+  }
+
+  return [
+    ...pickScaffold(originalTable.leadingLines, updatedTable.leadingLines),
+    ...mergedRows.map(renderRow),
+    ...pickScaffold(originalTable.trailingLines, updatedTable.trailingLines),
+  ].join("\n").trimEnd();
+}
+
+export function mergeCharacterMatrixMarkdown(original: string, updated: string): string {
+  const originalSections = parseSections(original);
+  const updatedSections = parseSections(updated);
+  if (originalSections.sections.length === 0 || updatedSections.sections.length === 0) {
+    return updated;
+  }
+
+  const sectionKeyColumns: ReadonlyArray<ReadonlyArray<number>> = [
+    [0],
+    [0, 1],
+    [0, 3],
+  ];
+
+  const mergedSections = originalSections.sections.map((section, index) => {
+    const next = updatedSections.sections[index];
+    if (!next) return section;
+
+    const keyColumns = sectionKeyColumns[index] ?? [0];
+    return {
+      heading: section.heading,
+      body: mergeTableMarkdownByKey(section.body, next.body, keyColumns),
+    };
+  });
+
+  for (let index = originalSections.sections.length; index < updatedSections.sections.length; index += 1) {
+    mergedSections.push(updatedSections.sections[index]!);
+  }
+
+  return [
+    ...pickScaffold(originalSections.topLines, updatedSections.topLines),
+    ...mergedSections.flatMap((section) => [section.heading, section.body]),
+  ].join("\n").trimEnd();
+}
+
+export function buildGovernedCharacterMatrixWorkingSet(params: {
+  readonly matrixMarkdown: string;
+  readonly chapterIntent: string;
+  readonly contextPackage: ContextPackage;
+  readonly protagonistName?: string;
+}): string {
+  const { matrixMarkdown } = params;
+  if (!matrixMarkdown || matrixMarkdown === "(文件不存在)" || matrixMarkdown === "(文件尚未创建)") {
+    return matrixMarkdown;
+  }
+
+  const parsed = parseSections(matrixMarkdown);
+  if (parsed.sections.length === 0) {
+    return matrixMarkdown;
+  }
+
+  const activeNames = collectGovernedCharacterNames(params);
+  const filteredSections = parsed.sections.map((section, index) => ({
+    heading: section.heading,
+    body: filterMatrixSection(section.body, index, activeNames),
+  }));
+
+  return [
+    ...parsed.topLines,
+    ...filteredSections.flatMap((section) => [section.heading, section.body]),
+  ].join("\n").trimEnd();
+}
+
+interface ParsedTable {
+  readonly leadingLines: string[];
+  readonly dataRows: string[][];
+  readonly trailingLines: string[];
+}
+
+function parseSingleTable(content: string): ParsedTable | null {
+  const lines = content.split("\n");
+  const tableIndexes = lines
+    .map((line, index) => ({ line, index }))
+    .filter((entry) => entry.line.trim().startsWith("|"))
+    .map((entry) => entry.index);
+  if (tableIndexes.length === 0) return null;
+
+  const headerStart = tableIndexes[0]!;
+  const nextIndex = tableIndexes[1];
+  const headerEnd = nextIndex !== undefined && lines[nextIndex]!.includes("---")
+    ? nextIndex
+    : headerStart;
+  const dataIndexes = tableIndexes.filter((index) => index > headerEnd);
+  const lastDataIndex = dataIndexes.length > 0 ? dataIndexes[dataIndexes.length - 1]! : headerEnd;
+
+  return {
+    leadingLines: lines.slice(0, headerEnd + 1),
+    dataRows: dataIndexes.map((index) => parseRow(lines[index]!)),
+    trailingLines: lines.slice(lastDataIndex + 1),
+  };
+}
+
+function parseSections(content: string): {
+  readonly topLines: string[];
+  readonly sections: Array<{ readonly heading: string; readonly body: string }>;
+} {
+  const lines = content.split("\n");
+  const topLines: string[] = [];
+  const sections: Array<{ heading: string; body: string }> = [];
+  let currentHeading: string | null = null;
+  let currentBody: string[] = [];
+
+  const flush = () => {
+    if (!currentHeading) return;
+    sections.push({
+      heading: currentHeading,
+      body: currentBody.join("\n").trimEnd(),
+    });
+  };
+
+  for (const line of lines) {
+    if (line.startsWith("### ")) {
+      flush();
+      currentHeading = line;
+      currentBody = [];
+      continue;
+    }
+
+    if (currentHeading) {
+      currentBody.push(line);
+    } else {
+      topLines.push(line);
+    }
+  }
+
+  flush();
+  return { topLines, sections };
+}
+
+function parseRow(line: string): string[] {
+  return line
+    .split("|")
+    .slice(1, -1)
+    .map((cell) => cell.trim());
+}
+
+function renderRow(row: ReadonlyArray<string>): string {
+  return `| ${row.join(" | ")} |`;
+}
+
+function buildKey(row: ReadonlyArray<string>, keyColumns: ReadonlyArray<number>): string {
+  return keyColumns.map((index) => row[index] ?? "").join("::");
+}
+
+function pickScaffold(primary: string[], fallback: string[]): string[] {
+  return primary.length > 0 ? primary : fallback;
+}
+
+function collectGovernedCharacterNames(params: {
+  readonly matrixMarkdown: string;
+  readonly chapterIntent: string;
+  readonly contextPackage: ContextPackage;
+  readonly protagonistName?: string;
+}): Set<string> {
+  const candidates = extractCharacterCandidatesFromMatrix(params.matrixMarkdown);
+  const corpus = [
+    params.chapterIntent,
+    ...params.contextPackage.selectedContext.flatMap((entry) => [
+      entry.reason,
+      entry.excerpt ?? "",
+    ]),
+  ].join("\n");
+
+  const activeNames = new Set<string>();
+  for (const candidate of candidates) {
+    if (params.protagonistName && matchesName(candidate, params.protagonistName)) {
+      activeNames.add(candidate);
+      continue;
+    }
+    if (isNameMentioned(candidate, corpus)) {
+      activeNames.add(candidate);
+    }
+  }
+
+  if (params.protagonistName) {
+    for (const candidate of candidates) {
+      if (matchesName(candidate, params.protagonistName)) {
+        activeNames.add(candidate);
+      }
+    }
+  }
+
+  return activeNames;
+}
+
+function extractCharacterCandidatesFromMatrix(matrixMarkdown: string): string[] {
+  const parsed = parseSections(matrixMarkdown);
+  const names = new Set<string>();
+
+  parsed.sections.forEach((section, index) => {
+    const table = parseSingleTable(section.body);
+    if (!table) return;
+
+    for (const row of table.dataRows) {
+      const candidates = index === 1
+        ? [row[0], row[1]]
+        : [row[0]];
+      for (const candidate of candidates) {
+        const normalized = candidate?.trim();
+        if (normalized) {
+          names.add(normalized);
+        }
+      }
+    }
+  });
+
+  return [...names];
+}
+
+function filterMatrixSection(
+  sectionBody: string,
+  sectionIndex: number,
+  activeNames: ReadonlySet<string>,
+): string {
+  const table = parseSingleTable(sectionBody);
+  if (!table) {
+    return sectionBody;
+  }
+
+  const filteredRows = table.dataRows.filter((row) => {
+    if (row.length === 0) return false;
+
+    if (sectionIndex === 1) {
+      const left = row[0] ?? "";
+      const right = row[1] ?? "";
+      return activeNames.has(left) && (right.length === 0 || activeNames.has(right));
+    }
+
+    return activeNames.has(row[0] ?? "");
+  });
+
+  return [
+    ...table.leadingLines,
+    ...filteredRows.map(renderRow),
+    ...table.trailingLines,
+  ].join("\n").trimEnd();
+}
+
+function isNameMentioned(candidate: string, corpus: string): boolean {
+  if (!candidate || !corpus) return false;
+
+  if (containsCjk(candidate)) {
+    return corpus.includes(candidate);
+  }
+
+  return new RegExp(`\\b${escapeRegExp(candidate)}\\b`, "i").test(corpus);
+}
+
+function matchesName(left: string, right: string): boolean {
+  if (!left || !right) return false;
+  return left.trim().toLowerCase() === right.trim().toLowerCase();
+}
+
+function containsCjk(value: string): boolean {
+  return /[\u4e00-\u9fff]/.test(value);
+}
+
+function escapeRegExp(value: string): string {
+  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}

+ 305 - 0
packages/core/src/utils/long-span-fatigue.ts

@@ -0,0 +1,305 @@
+import { readFile, readdir } from "node:fs/promises";
+import { join } from "node:path";
+
+export interface LongSpanFatigueIssue {
+  readonly severity: "warning";
+  readonly category: string;
+  readonly description: string;
+  readonly suggestion: string;
+}
+
+export interface AnalyzeLongSpanFatigueInput {
+  readonly bookDir: string;
+  readonly chapterNumber: number;
+  readonly chapterContent: string;
+  readonly chapterSummary?: string;
+  readonly language?: "zh" | "en";
+}
+
+interface SummaryRow {
+  readonly chapter: number;
+  readonly title: string;
+  readonly mood: string;
+  readonly chapterType: string;
+}
+
+const SENTENCE_SIMILARITY_THRESHOLD = 0.72;
+const CHINESE_PUNCTUATION = /[,。!?;:“”‘’()《》、\s\-—…·]/g;
+const ENGLISH_PUNCTUATION = /[^a-z0-9]+/gi;
+
+export async function analyzeLongSpanFatigue(
+  input: AnalyzeLongSpanFatigueInput,
+): Promise<{ readonly issues: ReadonlyArray<LongSpanFatigueIssue> }> {
+  const language = input.language ?? "zh";
+  const issues: LongSpanFatigueIssue[] = [];
+
+  const summaryRows = await loadSummaryRows(join(input.bookDir, "story", "chapter_summaries.md"));
+  const mergedRows = mergeCurrentSummary(summaryRows, input.chapterSummary);
+  const recentRows = mergedRows
+    .filter((row) => row.chapter <= input.chapterNumber)
+    .sort((left, right) => left.chapter - right.chapter)
+    .slice(-3);
+
+  const chapterTypeIssue = buildChapterTypeIssue(recentRows, language);
+  if (chapterTypeIssue) {
+    issues.push(chapterTypeIssue);
+  }
+
+  const recentChapterBodies = await loadRecentChapterBodies(
+    input.bookDir,
+    input.chapterNumber,
+    input.chapterContent,
+  );
+
+  const openingIssue = buildSentencePatternIssue(recentChapterBodies, "opening", language);
+  if (openingIssue) {
+    issues.push(openingIssue);
+  }
+
+  const endingIssue = buildSentencePatternIssue(recentChapterBodies, "ending", language);
+  if (endingIssue) {
+    issues.push(endingIssue);
+  }
+
+  return { issues };
+}
+
+async function loadSummaryRows(path: string): Promise<SummaryRow[]> {
+  try {
+    const raw = await readFile(path, "utf-8");
+    return raw
+      .split("\n")
+      .map((line) => parseSummaryRow(line))
+      .filter((row): row is SummaryRow => row !== null);
+  } catch {
+    return [];
+  }
+}
+
+function mergeCurrentSummary(rows: ReadonlyArray<SummaryRow>, currentSummary?: string): SummaryRow[] {
+  const parsedCurrent = currentSummary ? parseSummaryRow(currentSummary) : null;
+  if (!parsedCurrent) return [...rows];
+
+  const nextRows = rows.filter((row) => row.chapter !== parsedCurrent.chapter);
+  nextRows.push(parsedCurrent);
+  return nextRows;
+}
+
+function parseSummaryRow(line: string): SummaryRow | null {
+  const trimmed = line.trim();
+  if (!trimmed.startsWith("|") || trimmed.includes("章节 |") || trimmed.includes("Chapter |") || trimmed.includes("---")) {
+    return null;
+  }
+
+  const cells = trimmed
+    .split("|")
+    .map((cell) => cell.trim())
+    .filter((cell) => cell.length > 0);
+  if (cells.length < 8) {
+    return null;
+  }
+
+  const chapter = Number.parseInt(cells[0] ?? "", 10);
+  if (!Number.isFinite(chapter) || chapter <= 0) {
+    return null;
+  }
+
+  return {
+    chapter,
+    title: cells[1] ?? "",
+    mood: cells[6] ?? "",
+    chapterType: cells[7] ?? "",
+  };
+}
+
+function buildChapterTypeIssue(
+  rows: ReadonlyArray<SummaryRow>,
+  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])) {
+    return null;
+  }
+
+  if (language === "en") {
+    return {
+      severity: "warning",
+      category: "Pacing Monotony",
+      description: `The last 3 chapter types are identical: ${types.join(" -> ")}, 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.",
+    };
+  }
+
+  return {
+    severity: "warning",
+    category: "节奏单调",
+    description: `最近3章章节类型完全一致:${types.join(" -> ")},长篇节奏可能开始固化。`,
+    suggestion: "下一章应切换章节功能,不要连续重复同一种布局/推进节拍。",
+  };
+}
+
+async function loadRecentChapterBodies(
+  bookDir: string,
+  currentChapter: number,
+  currentContent: string,
+): Promise<string[]> {
+  const chaptersDir = join(bookDir, "chapters");
+  try {
+    const files = await readdir(chaptersDir);
+    const previousFiles = files
+      .map((file) => ({ file, chapter: Number.parseInt(file.slice(0, 4), 10) }))
+      .filter((entry) => Number.isFinite(entry.chapter) && entry.chapter < currentChapter && entry.file.endsWith(".md"))
+      .sort((left, right) => left.chapter - right.chapter)
+      .slice(-2);
+
+    if (previousFiles.length < 2) {
+      return [];
+    }
+
+    const previousBodies = await Promise.all(
+      previousFiles.map((entry) => readFile(join(chaptersDir, entry.file), "utf-8")),
+    );
+
+    return [...previousBodies, currentContent];
+  } catch {
+    return [];
+  }
+}
+
+function buildSentencePatternIssue(
+  chapterBodies: ReadonlyArray<string>,
+  boundary: "opening" | "ending",
+  language: "zh" | "en",
+): LongSpanFatigueIssue | null {
+  if (chapterBodies.length < 3) return null;
+
+  const sentences = chapterBodies.map((body) => extractBoundarySentence(body, boundary));
+  if (sentences.some((sentence) => sentence === null)) {
+    return null;
+  }
+
+  const normalized = sentences
+    .map((sentence) => normalizeSentence(sentence!, language));
+  if (normalized.some((sentence) => sentence.length < 18)) {
+    return null;
+  }
+
+  const similarities = [
+    diceCoefficient(normalized[0]!, normalized[1]!),
+    diceCoefficient(normalized[1]!, normalized[2]!),
+  ];
+  if (Math.min(...similarities) < SENTENCE_SIMILARITY_THRESHOLD) {
+    return null;
+  }
+
+  const sample = summarizeSentence(sentences[2]!, language);
+  const pairText = similarities.map((value) => value.toFixed(2)).join("/");
+
+  if (language === "en") {
+    const category = boundary === "opening" ? "Opening Pattern Repetition" : "Ending Pattern Repetition";
+    const position = boundary === "opening" ? "openings" : "endings";
+    return {
+      severity: "warning",
+      category,
+      description: `The last 3 chapter ${position} are highly similar (adjacent similarity ${pairText}), which risks a formulaic rhythm. Current ${boundary} signature: "${sample}".`,
+      suggestion: boundary === "opening"
+        ? "Change the next chapter opening vector. Start from action, consequence, or surprise instead of repeating the same camera move."
+        : "Change the next chapter landing pattern. End on consequence, decision, or a new variable instead of repeating the same explanatory cadence.",
+    };
+  }
+
+  return {
+    severity: "warning",
+    category: boundary === "opening" ? "开头同构" : "结尾同构",
+    description: `最近3章${boundary === "opening" ? "开头" : "结尾"}句式高度相似(相邻相似度${pairText}),容易形成模板化${boundary === "opening" ? "开篇" : "章尾"}。当前句式近似“${sample}”。`,
+    suggestion: boundary === "opening"
+      ? "下一章换一个开篇入口,用动作、后果或异常信息切入,不要连续沿用同一种抬镜句。"
+      : "下一章换一个收束方式,用行动后果、角色决断或新变量落板,不要连续用解释性句子收尾。",
+  };
+}
+
+function extractBoundarySentence(content: string, boundary: "opening" | "ending"): string | null {
+  const flattened = content
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.length > 0 && !line.startsWith("#"))
+    .join(" ");
+
+  const sentences = flattened
+    .split(/(?<=[。!?!?\.])\s+/)
+    .map((sentence) => sentence.trim())
+    .filter((sentence) => sentence.length > 0);
+
+  if (sentences.length === 0) {
+    return null;
+  }
+
+  return boundary === "opening" ? sentences[0]! : sentences[sentences.length - 1]!;
+}
+
+function normalizeSentence(sentence: string, language: "zh" | "en"): string {
+  if (language === "en") {
+    return sentence
+      .toLowerCase()
+      .replace(ENGLISH_PUNCTUATION, "")
+      .trim();
+  }
+
+  return sentence
+    .replace(CHINESE_PUNCTUATION, "")
+    .toLowerCase();
+}
+
+function summarizeSentence(sentence: string, language: "zh" | "en"): string {
+  if (language === "en") {
+    const words = sentence
+      .toLowerCase()
+      .replace(/[^a-z0-9\s]+/gi, " ")
+      .split(/\s+/)
+      .filter(Boolean)
+      .slice(0, 6)
+      .join(" ");
+    return words.length > 0 ? words : sentence.slice(0, 32);
+  }
+
+  const collapsed = sentence.replace(CHINESE_PUNCTUATION, "");
+  return collapsed.slice(0, 12);
+}
+
+function diceCoefficient(left: string, right: string): number {
+  if (left === right) return 1;
+  if (left.length < 2 || right.length < 2) return 0;
+
+  const leftBigrams = buildBigrams(left);
+  const rightBigrams = buildBigrams(right);
+  let overlap = 0;
+
+  for (const [bigram, count] of leftBigrams) {
+    overlap += Math.min(count, rightBigrams.get(bigram) ?? 0);
+  }
+
+  const leftCount = [...leftBigrams.values()].reduce((sum, value) => sum + value, 0);
+  const rightCount = [...rightBigrams.values()].reduce((sum, value) => sum + value, 0);
+  return (2 * overlap) / (leftCount + rightCount);
+}
+
+function buildBigrams(value: string): Map<string, number> {
+  const result = new Map<string, number>();
+  for (let index = 0; index < value.length - 1; index++) {
+    const bigram = value.slice(index, index + 2);
+    result.set(bigram, (result.get(bigram) ?? 0) + 1);
+  }
+  return result;
+}
+
+function isMeaningfulValue(value: string): boolean {
+  const normalized = value.trim().toLowerCase();
+  return normalized.length > 0 && normalized !== "none" && normalized !== "(none)" && normalized !== "无";
+}

+ 102 - 0
packages/core/src/utils/spot-fix-patches.ts

@@ -0,0 +1,102 @@
+export interface SpotFixPatch {
+  readonly targetText: string;
+  readonly replacementText: string;
+}
+
+export interface SpotFixPatchApplyResult {
+  readonly applied: boolean;
+  readonly revisedContent: string;
+  readonly rejectedReason?: string;
+  readonly appliedPatchCount: number;
+  readonly touchedChars: number;
+}
+
+const MAX_SPOT_FIX_TOUCHED_RATIO = 0.25;
+
+export function parseSpotFixPatches(raw: string): SpotFixPatch[] {
+  const normalized = raw.includes("=== PATCHES ===")
+    ? raw.slice(raw.indexOf("=== PATCHES ===") + "=== PATCHES ===".length)
+    : raw;
+
+  const patches: SpotFixPatch[] = [];
+  const regex = /--- PATCH(?:\s+\d+)? ---\s*TARGET_TEXT:\s*([\s\S]*?)\s*REPLACEMENT_TEXT:\s*([\s\S]*?)\s*--- END PATCH ---/g;
+
+  let match: RegExpExecArray | null;
+  while ((match = regex.exec(normalized)) !== null) {
+    patches.push({
+      targetText: trimField(match[1] ?? ""),
+      replacementText: trimField(match[2] ?? ""),
+    });
+  }
+
+  return patches.filter((patch) => patch.targetText.length > 0);
+}
+
+export function applySpotFixPatches(
+  original: string,
+  patches: ReadonlyArray<SpotFixPatch>,
+): SpotFixPatchApplyResult {
+  if (patches.length === 0) {
+    return {
+      applied: false,
+      revisedContent: original,
+      rejectedReason: "No valid patches returned.",
+      appliedPatchCount: 0,
+      touchedChars: 0,
+    };
+  }
+
+  const touchedChars = patches.reduce((sum, patch) => sum + patch.targetText.length, 0);
+  if (original.length > 0 && touchedChars / original.length > MAX_SPOT_FIX_TOUCHED_RATIO) {
+    return {
+      applied: false,
+      revisedContent: original,
+      rejectedReason: "Patch set would touch too much of the chapter.",
+      appliedPatchCount: 0,
+      touchedChars,
+    };
+  }
+
+  let current = original;
+
+  for (const patch of patches) {
+    const start = current.indexOf(patch.targetText);
+    if (start === -1) {
+      return {
+        applied: false,
+        revisedContent: original,
+        rejectedReason: "Each TARGET_TEXT must match the chapter exactly once.",
+        appliedPatchCount: 0,
+        touchedChars,
+      };
+    }
+
+    const another = current.indexOf(patch.targetText, start + patch.targetText.length);
+    if (another !== -1) {
+      return {
+        applied: false,
+        revisedContent: original,
+        rejectedReason: "Each TARGET_TEXT must match the chapter exactly once.",
+        appliedPatchCount: 0,
+        touchedChars,
+      };
+    }
+
+    current = [
+      current.slice(0, start),
+      patch.replacementText,
+      current.slice(start + patch.targetText.length),
+    ].join("");
+  }
+
+  return {
+    applied: current !== original,
+    revisedContent: current,
+    appliedPatchCount: patches.length,
+    touchedChars,
+  };
+}
+
+function trimField(value: string): string {
+  return value.replace(/^\s*\n/, "").replace(/\n\s*$/, "").trim();
+}