浏览代码

fix(v9): sanitize governed prompts and constrain auto outputs

Ma 7 月之前
父节点
当前提交
6d8191845b

+ 246 - 4
packages/core/src/__tests__/reviser.test.ts

@@ -299,6 +299,246 @@ describe("ReviserAgent", () => {
     }
   });
 
+  it("ignores REVISED_CONTENT for auto mode when issues are local-only and PATCHES are available", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-reviser-auto-local-only-test-"));
+    const bookDir = join(root, "book");
+    await mkdir(join(bookDir, "story"), { recursive: true });
+
+    const agent = new ReviserAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: [
+        "=== FIXED_ISSUES ===",
+        "- removed the AI tell",
+        "",
+        "=== PATCHES ===",
+        "--- PATCH 1 ---",
+        "TARGET_TEXT:",
+        "他仿佛听见门外有响动。",
+        "REPLACEMENT_TEXT:",
+        "他听见门外像有一点轻响。",
+        "--- END PATCH ---",
+        "",
+        "=== REVISED_CONTENT ===",
+        "整章重写后的版本,不应该被局部问题采用。",
+        "",
+        "=== UPDATED_STATE ===",
+        "状态卡",
+        "",
+        "=== UPDATED_HOOKS ===",
+        "伏笔池",
+      ].join("\n"),
+      usage: ZERO_USAGE,
+    });
+
+    try {
+      const result = await agent.reviseChapter(
+        bookDir,
+        "他仿佛听见门外有响动。\n\n他没有回头。",
+        1,
+        [{
+          severity: "warning",
+          category: "套话密度",
+          description: "仿佛用得太直接",
+          suggestion: "改成更具体的感官描写",
+        }],
+        "auto",
+        "xuanhuan",
+      );
+
+      expect(result.revisedContent).toContain("他听见门外像有一点轻响。");
+      expect(result.revisedContent).not.toContain("整章重写后的版本");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("keeps REVISED_CONTENT available for auto mode when issues are whole-chapter", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-reviser-auto-whole-chapter-test-"));
+    const bookDir = join(root, "book");
+    await mkdir(join(bookDir, "story"), { recursive: true });
+
+    const agent = new ReviserAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: [
+        "=== FIXED_ISSUES ===",
+        "- restructured chapter pacing",
+        "",
+        "=== PATCHES ===",
+        "--- PATCH 1 ---",
+        "TARGET_TEXT:",
+        "第一段。",
+        "REPLACEMENT_TEXT:",
+        "第一段(局部修补)。",
+        "--- END PATCH ---",
+        "",
+        "=== REVISED_CONTENT ===",
+        "整章重写后的版本,处理了整体节奏与结构。",
+        "",
+        "=== UPDATED_STATE ===",
+        "状态卡",
+        "",
+        "=== UPDATED_HOOKS ===",
+        "伏笔池",
+      ].join("\n"),
+      usage: ZERO_USAGE,
+    });
+
+    try {
+      const result = await agent.reviseChapter(
+        bookDir,
+        "第一段。\n\n第二段。\n\n第三段。",
+        1,
+        [{
+          severity: "critical",
+          category: "Outline Drift Check",
+          description: "整章结构已经偏离",
+          suggestion: "重建当前章节奏与组织",
+        }],
+        "auto",
+        "xuanhuan",
+      );
+
+      expect(result.revisedContent).toContain("整章重写后的版本");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("sanitizes reduced governed control input so raw hook ids and source labels do not enter reviser prompts", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-reviser-governed-sanitize-test-"));
+    const bookDir = join(root, "book");
+    await mkdir(join(bookDir, "story"), { recursive: true });
+
+    const agent = new ReviserAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: [
+        "=== FIXED_ISSUES ===",
+        "- fixed",
+        "",
+        "=== PATCHES ===",
+        "--- PATCH 1 ---",
+        "TARGET_TEXT:",
+        "原始正文。",
+        "REPLACEMENT_TEXT:",
+        "修订后的正文。",
+        "--- END PATCH ---",
+        "",
+        "=== UPDATED_STATE ===",
+        "状态卡",
+        "",
+        "=== UPDATED_HOOKS ===",
+        "伏笔池",
+      ].join("\n"),
+      usage: ZERO_USAGE,
+    });
+
+    try {
+      await agent.reviseChapter(
+        bookDir,
+        "原始正文。",
+        1,
+        [CRITICAL_ISSUE],
+        "auto",
+        "xuanhuan",
+        {
+          chapterIntent: [
+            "# Chapter Intent",
+            "",
+            "## Goal",
+            "Bring the focus back to the mentor oath conflict.",
+            "",
+            "## Must Avoid",
+            "- 前几章回顾式总结",
+            "- 本章要做的是把 H001/H002 推下去",
+            "",
+            "## Hook Agenda",
+            "### Resolve",
+            "- H001",
+            "",
+            "### Advance",
+            "- H002",
+          ].join("\n"),
+          contextPackage: {
+            chapter: 1,
+            selectedContext: [
+              {
+                source: "runtime/hook_debt#H001",
+                reason: "Narrative debt brief with original seed text for this hook agenda target.",
+                excerpt: "H001 | original seed (ch1): the oath debt first surfaced",
+              },
+            ],
+          },
+          ruleStack: {
+            layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
+            sections: {
+              hard: ["current_state"],
+              soft: ["current_focus"],
+              diagnostic: ["continuity_audit"],
+            },
+            overrideEdges: [],
+            activeOverrides: [],
+          },
+        },
+      );
+
+      const userPrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
+      expect(userPrompt).not.toContain("runtime/hook_debt#H001");
+      expect(userPrompt).not.toContain("## Hook Agenda");
+      expect(userPrompt).not.toContain("H001");
+      expect(userPrompt).not.toContain("H002");
+      expect(userPrompt).not.toContain("前几章");
+      expect(userPrompt).not.toContain("本章要做的");
+      expect(userPrompt).toContain("Bring the focus back to the mentor oath conflict.");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("uses selected summary and hook evidence instead of full long-history markdown in governed mode", async () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-reviser-governed-test-"));
     const bookDir = join(root, "book");
@@ -452,10 +692,12 @@ describe("ReviserAgent", () => {
         | undefined;
       const userPrompt = messages?.[1]?.content ?? "";
 
-      expect(userPrompt).toContain("story/chapter_summaries.md#99");
-      expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath");
-      expect(userPrompt).toContain("story/story_bible.md");
-      expect(userPrompt).toContain("story/volume_outline.md");
+      expect(userPrompt).not.toContain("story/chapter_summaries.md#99");
+      expect(userPrompt).not.toContain("story/pending_hooks.md#mentor-oath");
+      expect(userPrompt).not.toContain("story/story_bible.md");
+      expect(userPrompt).not.toContain("story/volume_outline.md");
+      expect(userPrompt).toContain("The jade seal cannot be destroyed.");
+      expect(userPrompt).toContain("Track the mentor oath fallout.");
       expect(userPrompt).not.toContain("| 1 | Guild Trail |");
       expect(userPrompt).not.toContain("guild-route | 1 | mystery");
       expect(userPrompt).not.toContain("Guildmaster Ren secretly forged the harbor roster in chapter 140.");

+ 15 - 6
packages/core/src/__tests__/writer.test.ts

@@ -1077,7 +1077,7 @@ describe("WriterAgent", () => {
     }
   });
 
-  it("renders an explicit hook agenda block and removes placeholder hook ids from the governed write contract", async () => {
+  it("sanitizes governed control inputs so raw hook ids and control headings do not enter the creative prompt", async () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-writer-hook-agenda-test-"));
     const bookDir = join(root, "book");
     const storyDir = join(bookDir, "story");
@@ -1174,6 +1174,10 @@ describe("WriterAgent", () => {
           "## Goal",
           "Push Mara back toward the archive ledger.",
           "",
+          "## Must Avoid",
+          "- 不要写成前几章回顾",
+          "- 本章要做的是推进 ledger-fragment,但不要把 H001/H002 写进正文",
+          "",
           "## Hook Agenda",
           "### Must Advance",
           "- mentor-oath",
@@ -1215,11 +1219,16 @@ describe("WriterAgent", () => {
 
       expect(systemPrompt).not.toContain("Hook-A / Hook-B");
       expect(systemPrompt).toContain("真实 hook_id");
-      expect(creativePrompt).toContain("## Explicit Hook Agenda");
-      expect(creativePrompt).toContain("mentor-oath");
-      expect(creativePrompt).toContain("ledger-fragment");
-      expect(creativePrompt).toContain("stale-ledger");
-      expect(creativePrompt).toContain("relationship");
+      expect(creativePrompt).not.toContain("## Explicit Hook Agenda");
+      expect(creativePrompt).not.toContain("## Hook Agenda");
+      expect(creativePrompt).not.toContain("mentor-oath");
+      expect(creativePrompt).not.toContain("ledger-fragment");
+      expect(creativePrompt).not.toContain("stale-ledger");
+      expect(creativePrompt).not.toContain("H001");
+      expect(creativePrompt).not.toContain("H002");
+      expect(creativePrompt).not.toContain("前几章");
+      expect(creativePrompt).not.toContain("本章要做的");
+      expect(creativePrompt).toContain("Push Mara back toward the archive ledger.");
     } finally {
       await rm(root, { recursive: true, force: true });
     }

+ 60 - 8
packages/core/src/agents/reviser.ts

@@ -14,6 +14,11 @@ import {
   mergeTableMarkdownByKey,
 } from "../utils/governed-working-set.js";
 import { applySpotFixPatches, parseSpotFixPatches } from "../utils/spot-fix-patches.js";
+import {
+  buildNarrativeIntentBrief,
+  renderNarrativeSelectedContext,
+  sanitizeNarrativeEvidenceBlock,
+} from "../utils/narrative-control.js";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -35,6 +40,8 @@ export interface ReviseOutput {
   };
 }
 
+type AutoOutputMode = "patch-only" | "allow-full";
+
 function buildTieredIssueList(
   issues: ReadonlyArray<AuditIssue>,
   isEnglish: boolean,
@@ -247,7 +254,7 @@ ${issueList}
 ## 当前状态卡
 ${currentState}
 ${ledgerBlock}
-${hookDebtBlock}${hooksBlock}${volumeSummariesBlock}${reducedControlBlock || outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${styleGuideBlock}${lengthGuidanceBlock}
+${sanitizeNarrativeEvidenceBlock(hookDebtBlock, resolvedLanguage) ?? ""}${sanitizeNarrativeEvidenceBlock(hooksBlock, resolvedLanguage) ?? ""}${sanitizeNarrativeEvidenceBlock(volumeSummariesBlock, resolvedLanguage) ?? ""}${reducedControlBlock || outlineBlock}${bibleBlock}${matrixBlock}${sanitizeNarrativeEvidenceBlock(summariesBlock, resolvedLanguage) ?? ""}${canonBlock}${fanficCanonBlock}${styleGuideBlock}${lengthGuidanceBlock}
 
 ## 待修正章节
 ${chapterContent}`;
@@ -260,7 +267,13 @@ ${chapterContent}`;
       { temperature: 0.3, maxTokens: 16384 },
     );
 
-    const output = this.parseOutput(response.content, gp, mode, chapterContent);
+    const output = this.parseOutput(
+      response.content,
+      gp,
+      mode,
+      chapterContent,
+      mode === "auto" ? resolveAutoOutputMode(issues) : "allow-full",
+    );
     const mergedOutput = governedMode
       ? {
           ...output,
@@ -278,6 +291,7 @@ ${chapterContent}`;
     gp: GenreProfile,
     mode: ReviseMode,
     originalChapter: string,
+    autoOutputMode: AutoOutputMode = "allow-full",
   ): ReviseOutput {
     const extract = (tag: string): string => {
       const regex = new RegExp(
@@ -304,9 +318,22 @@ ${chapterContent}`;
       updatedHooks: extract("UPDATED_HOOKS") || "(伏笔池未更新)",
     });
 
-    // Auto mode: REVISED_CONTENT takes priority (whole-chapter fix),
-    // PATCHES only used when REVISED_CONTENT is absent (local-only fix).
+    // Auto mode: only allow REVISED_CONTENT for whole-chapter issue sets.
     if (mode === "auto") {
+      if (autoOutputMode === "patch-only") {
+        const patchesRaw = extract("PATCHES");
+        if (patchesRaw) {
+          const patches = parseSpotFixPatches(patchesRaw);
+          if (patches.length > 0) {
+            const patchResult = applySpotFixPatches(originalChapter, patches);
+            if (patchResult.applied && patchResult.appliedPatchCount / patches.length >= 0.5) {
+              return makeResult(patchResult.revisedContent, true);
+            }
+          }
+        }
+        return makeResult(originalChapter, false);
+      }
+
       const revisedContent = extract("REVISED_CONTENT");
       if (revisedContent) {
         return makeResult(revisedContent, true);
@@ -513,17 +540,17 @@ ${outputFormat}`;
     contextPackage: ContextPackage,
     ruleStack: RuleStack,
   ): string {
-    const selectedContext = contextPackage.selectedContext
-      .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`)
-      .join("\n");
+    const selectedContext = renderNarrativeSelectedContext(contextPackage.selectedContext, "zh")
+      .replace(/^### /gm, "- ");
     const overrides = ruleStack.activeOverrides.length > 0
       ? ruleStack.activeOverrides
         .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`)
         .join("\n")
       : "- none";
+    const narrativeIntent = buildNarrativeIntentBrief(chapterIntent, "zh");
 
     return `\n## 本章控制输入(由 Planner/Composer 编译)
-${chapterIntent}
+${narrativeIntent || "(无)"}
 
 ### 已选上下文
 ${selectedContext || "- none"}
@@ -537,3 +564,28 @@ ${selectedContext || "- none"}
 ${overrides}\n`;
   }
 }
+
+function resolveAutoOutputMode(issues: ReadonlyArray<AuditIssue>): AutoOutputMode {
+  if (issues.length === 0) {
+    return "allow-full";
+  }
+
+  const localOnlyPatterns: ReadonlyArray<RegExp> = [
+    /Paragraph uniformity|段落等长/i,
+    /Hedge density|套话密度/i,
+    /Formulaic transitions|公式化转折/i,
+    /List-like structure|列表式结构/i,
+    /Cross-chapter repetition|跨章重复/i,
+    /AI-tell word density/i,
+    /Fatigue word|高疲劳词/i,
+    /Information Boundary Check|信息越界/i,
+    /Knowledge Base Pollution|知识库污染/i,
+  ];
+
+  const isLocalOnly = issues.every((issue) => {
+    const text = `${issue.category} ${issue.description}`;
+    return localOnlyPatterns.some((pattern) => pattern.test(text));
+  });
+
+  return isLocalOnly ? "patch-only" : "allow-full";
+}

+ 19 - 46
packages/core/src/agents/writer.ts

@@ -34,6 +34,11 @@ import type { RuntimeStateSnapshot } from "../state/state-reducer.js";
 import { parsePendingHooksMarkdown } from "../utils/memory-retrieval.js";
 import { analyzeHookHealth } from "../utils/hook-health.js";
 import { buildEnglishVarianceBrief } from "../utils/long-span-fatigue.js";
+import {
+  buildNarrativeIntentBrief,
+  renderNarrativeSelectedContext,
+  sanitizeNarrativeEvidenceBlock,
+} from "../utils/narrative-control.js";
 import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -814,13 +819,11 @@ ${lengthRequirementBlock}
     readonly varianceBrief?: string;
     readonly selectedEvidenceBlock?: string;
   }): string {
-    const contextSections = params.contextPackage.selectedContext
-      .map((entry) => [
-        `### ${entry.source}`,
-        `- reason: ${entry.reason}`,
-        entry.excerpt ? `- excerpt: ${entry.excerpt}` : "",
-      ].filter(Boolean).join("\n"))
-      .join("\n\n");
+    const language = params.language ?? "zh";
+    const contextSections = renderNarrativeSelectedContext(
+      params.contextPackage.selectedContext,
+      language,
+    );
 
     const overrideLines = params.ruleStack.activeOverrides.length > 0
       ? params.ruleStack.activeOverrides
@@ -840,25 +843,19 @@ ${lengthRequirementBlock}
       ? `\n${params.varianceBrief}\n`
       : "";
     const selectedEvidenceBlock = params.selectedEvidenceBlock
-      ? `\n${params.selectedEvidenceBlock}\n`
-      : "";
-    const explicitHookAgenda = this.extractMarkdownSection(params.chapterIntent, "## Hook Agenda");
-    const hookAgendaBlock = explicitHookAgenda
-      ? params.language === "en"
-        ? `\n## Explicit Hook Agenda\n${explicitHookAgenda}\n`
-        : `\n## 显式 Hook Agenda\n${explicitHookAgenda}\n`
+      ? `\n${sanitizeNarrativeEvidenceBlock(params.selectedEvidenceBlock, language)}\n`
       : "";
+    const narrativeIntent = buildNarrativeIntentBrief(params.chapterIntent, language);
 
     if (params.language === "en") {
       return `Write chapter ${params.chapterNumber}.
 
 ## Chapter Intent
-${params.chapterIntent}
+${narrativeIntent || "(none)"}
 
 ## Selected Context
 ${contextSections || "(none)"}
 ${selectedEvidenceBlock}
-${hookAgendaBlock}
 
 ## Rule Stack
 - Hard: ${params.ruleStack.sections.hard.join(", ") || "(none)"}
@@ -880,12 +877,11 @@ ${lengthRequirementBlock}
     return `请续写第${params.chapterNumber}章。
 
 ## 本章意图
-${params.chapterIntent}
+${narrativeIntent || "(无)"}
 
 ## 已选上下文
 ${contextSections || "(无)"}
 ${selectedEvidenceBlock}
-${hookAgendaBlock}
 
 ## 规则栈
 - 硬护栏:${params.ruleStack.sections.hard.join("、") || "(无)"}
@@ -924,47 +920,24 @@ ${lengthRequirementBlock}
     return joined || undefined;
   }
 
-  private extractMarkdownSection(content: string, heading: string): string | undefined {
-    const lines = content.split("\n");
-    let buffer: string[] | null = null;
-
-    for (const line of lines) {
-      if (line.trim() === heading) {
-        buffer = [];
-        continue;
-      }
-
-      if (buffer && line.startsWith("## ") && line.trim() !== heading) {
-        break;
-      }
-
-      if (buffer) {
-        buffer.push(line);
-      }
-    }
-
-    const section = buffer?.join("\n").trim();
-    return section && section.length > 0 ? section : undefined;
-  }
-
   private buildSettlerGovernedControlBlock(
     chapterIntent: string,
     contextPackage: ContextPackage,
     ruleStack: RuleStack,
     language: "zh" | "en",
   ): string {
-    const selectedContext = contextPackage.selectedContext
-      .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`)
-      .join("\n");
+    const selectedContext = renderNarrativeSelectedContext(contextPackage.selectedContext, language)
+      .replace(/^### /gm, "- ");
     const overrides = ruleStack.activeOverrides.length > 0
       ? ruleStack.activeOverrides
         .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`)
         .join("\n")
       : "- none";
+    const narrativeIntent = buildNarrativeIntentBrief(chapterIntent, language);
 
     if (language === "en") {
       return `\n## Chapter Control Inputs
-${chapterIntent}
+${narrativeIntent || "(none)"}
 
 ### Selected Context
 ${selectedContext || "- none"}
@@ -979,7 +952,7 @@ ${overrides}\n`;
     }
 
     return `\n## 本章控制输入
-${chapterIntent}
+${narrativeIntent || "(无)"}
 
 ### 已选上下文
 ${selectedContext || "- none"}

+ 134 - 0
packages/core/src/utils/narrative-control.ts

@@ -0,0 +1,134 @@
+import type { ContextPackage } from "../models/input-governance.js";
+
+const HOOK_ID_PATTERN = /\bH\d+\b/gi;
+const HOOK_SLUG_PATTERN = /\b[a-z]+(?:-[a-z]+){1,3}\b/g;
+const CHAPTER_REF_PATTERNS: ReadonlyArray<RegExp> = [
+  /\bch(?:apter)?\s*\d+\b/gi,
+  /第\s*\d+\s*章/g,
+];
+
+const ZH_REPLACEMENTS: ReadonlyArray<[RegExp, string]> = [
+  [/前几章/g, "此前"],
+  [/本章要做的是/g, "眼下要处理的是"],
+  [/本章要做的/g, "眼下要处理的"],
+  [/仿佛/g, "像"],
+  [/似乎/g, "像是"],
+];
+
+const EN_REPLACEMENTS: ReadonlyArray<[RegExp, string]> = [
+  [/\bprevious chapters\b/gi, "earlier scenes"],
+  [/\bthis chapter needs to\b/gi, "the current move is to"],
+];
+
+export function sanitizeNarrativeControlText(
+  text: string,
+  language: "zh" | "en" = "zh",
+): string {
+  let result = text;
+
+  result = result.replace(HOOK_ID_PATTERN, language === "en" ? "this thread" : "这条线索");
+  result = result.replace(HOOK_SLUG_PATTERN, language === "en" ? "this thread" : "这条线索");
+  for (const pattern of CHAPTER_REF_PATTERNS) {
+    result = result.replace(pattern, language === "en" ? "an earlier scene" : "此前");
+  }
+
+  for (const [pattern, replacement] of [...ZH_REPLACEMENTS, ...EN_REPLACEMENTS]) {
+    result = result.replace(pattern, replacement);
+  }
+
+  return result;
+}
+
+export function buildNarrativeIntentBrief(
+  chapterIntent: string,
+  language: "zh" | "en" = "zh",
+): string {
+  const sections = [
+    { heading: "## Goal", label: language === "en" ? "Goal" : "目标" },
+    { heading: "## Outline Node", label: language === "en" ? "Outline Node" : "当前节点" },
+    { heading: "## Must Keep", label: language === "en" ? "Keep" : "保留" },
+    { heading: "## Must Avoid", label: language === "en" ? "Avoid" : "避免" },
+    { heading: "## Style Emphasis", label: language === "en" ? "Style" : "风格" },
+    { heading: "## Structured Directives", label: language === "en" ? "Directives" : "指令" },
+  ] as const;
+
+  const rendered = sections
+    .map(({ heading, label }) => {
+      const section = extractMarkdownSection(chapterIntent, heading);
+      if (!section) return null;
+
+      const lines = section
+        .split("\n")
+        .map((line) => line.trim())
+        .filter(Boolean)
+        .filter((line) => !["- none", "- 无", "- 本轮无", "(not found)"].includes(line));
+      if (lines.length === 0) return null;
+
+      const normalized = lines
+        .map((line) => line.startsWith("- ") ? line.slice(2) : line)
+        .map((line) => sanitizeNarrativeControlText(line, language))
+        .filter(Boolean)
+        .map((line) => `- ${line}`)
+        .join("\n");
+
+      return `## ${label}\n${normalized}`;
+    })
+    .filter((section): section is string => Boolean(section));
+
+  return rendered.join("\n\n");
+}
+
+export function renderNarrativeSelectedContext(
+  entries: ReadonlyArray<ContextPackage["selectedContext"][number]>,
+  language: "zh" | "en" = "zh",
+): string {
+  const heading = language === "en" ? "Evidence" : "证据";
+  const reasonLabel = language === "en" ? "reason" : "原因";
+  const detailLabel = language === "en" ? "detail" : "细节";
+
+  return entries
+    .map((entry, index) => {
+      const lines = [
+        `### ${heading} ${index + 1}`,
+        `- ${reasonLabel}: ${sanitizeNarrativeControlText(entry.reason, language)}`,
+        entry.excerpt ? `- ${detailLabel}: ${sanitizeNarrativeControlText(entry.excerpt, language)}` : "",
+      ].filter(Boolean);
+      return lines.join("\n");
+    })
+    .join("\n\n");
+}
+
+export function sanitizeNarrativeEvidenceBlock(
+  block: string | undefined,
+  language: "zh" | "en" = "zh",
+): string | undefined {
+  if (!block) return undefined;
+  const withoutSources = block.replace(
+    /(^|\n)-\s+(?:story|runtime)\/[^:\n]+:\s*/g,
+    (_match, prefix: string) => `${prefix}- evidence: `,
+  );
+  return sanitizeNarrativeControlText(withoutSources, language);
+}
+
+function extractMarkdownSection(content: string, heading: string): string | undefined {
+  const lines = content.split("\n");
+  let buffer: string[] | null = null;
+
+  for (const line of lines) {
+    if (line.trim() === heading) {
+      buffer = [];
+      continue;
+    }
+
+    if (buffer && line.startsWith("## ") && line.trim() !== heading) {
+      break;
+    }
+
+    if (buffer) {
+      buffer.push(line);
+    }
+  }
+
+  const section = buffer?.join("\n").trim();
+  return section && section.length > 0 ? section : undefined;
+}