Prechádzať zdrojové kódy

fix-core-write-surface-and-brief

Ma 6 mesiacov pred
rodič
commit
fac31bda97

+ 36 - 0
packages/core/src/__tests__/chapter-review-cycle.test.ts

@@ -196,4 +196,40 @@ describe("runChapterReviewCycle v9", () => {
     expect(result.auditResult.overallScore).toBe(88);
     expect(result.revised).toBe(false);
   });
+
+  it("normalizes deterministic surface blockers before audit and repair", async () => {
+    const auditChapter = vi.fn()
+      .mockResolvedValue(createAuditResult({ overallScore: 90, passed: true }));
+    const reviseChapter = vi.fn();
+    const normalizeDraftLengthIfNeeded = vi.fn()
+      .mockImplementation(async (content: string) => ({
+        content,
+        wordCount: content.length,
+        applied: false,
+        tokenUsage: ZERO_USAGE,
+      }));
+    const unsafe = `${"雨".repeat(100)}——${"夜".repeat(98)}`;
+
+    const result = await runChapterReviewCycle({
+      ...baseParams,
+      initialOutput: {
+        content: unsafe,
+        wordCount: unsafe.length,
+        postWriteErrors: [],
+      },
+      createReviser: () => ({ reviseChapter }),
+      auditor: { auditChapter },
+      normalizeDraftLengthIfNeeded,
+      normalizePostWriteSurface: (content) => content.replace(/——+/g, ","),
+      runPostWriteChecks: (content) =>
+        content.includes("——")
+          ? [{ severity: "critical" as const, category: "禁止破折号", description: "出现了破折号", suggestion: "用逗号断句" }]
+          : [],
+    });
+
+    expect(auditChapter.mock.calls[0]?.[1]).not.toContain("——");
+    expect(result.finalContent).not.toContain("——");
+    expect(result.auditResult.passed).toBe(true);
+    expect(reviseChapter).not.toHaveBeenCalled();
+  });
 });

+ 2 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -491,6 +491,8 @@ describe("PipelineRunner", () => {
         .resolves.toContain("冷硬、克制、利益驱动");
       await expect(readFile(join(storyDir, "current_focus.md"), "utf-8"))
         .resolves.toContain("旧账线和港口势力网");
+      await expect(readFile(join(storyDir, "brief.md"), "utf-8"))
+        .resolves.toContain("近未来港口城");
     } finally {
       await rm(root, { recursive: true, force: true });
     }

+ 14 - 0
packages/core/src/__tests__/post-write-validator.test.ts

@@ -4,6 +4,7 @@ import {
   detectParagraphLengthDrift,
   detectParagraphShapeWarnings,
   resolveDuplicateTitle,
+  normalizePostWriteSurface,
   validatePostWrite,
   type PostWriteViolation,
 } from "../agents/post-write-validator.js";
@@ -28,6 +29,19 @@ function findRule(violations: ReadonlyArray<PostWriteViolation>, rule: string):
 }
 
 describe("validatePostWrite", () => {
+  it("strips model-side post-write note lines before persistence", () => {
+    const content = [
+      "他把U盘攥进手心,回头看了一眼档案室的黑窗。",
+      "",
+      "[polisher-note] 无。",
+      "[writer-note] 这里需要后续补伏笔。",
+    ].join("\n");
+
+    const normalized = normalizePostWriteSurface(content);
+
+    expect(normalized).toBe("他把U盘攥进手心,回头看了一眼档案室的黑窗。");
+  });
+
   it("returns no violations for clean content", () => {
     const content = "他走过去,端起杯子,灌了一口。外面的雨越下越大。\n\n她站在窗前,看着街上的行人匆匆走过。";
     const result = validatePostWrite(content, baseProfile, null);

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

@@ -16,6 +16,26 @@ export interface PostWriteViolation {
   readonly suggestion: string;
 }
 
+export function normalizePostWriteSurface(
+  content: string,
+  languageOverride?: "zh" | "en",
+): string {
+  let normalized = stripPostWriteMetaLines(content);
+  if (languageOverride !== "en") {
+    normalized = normalized.replace(/——+/g, ",");
+  }
+  return normalized.trimEnd();
+}
+
+function stripPostWriteMetaLines(content: string): string {
+  const lines = content.split(/\r?\n/);
+  const filtered = lines.filter((line) =>
+    !/^\s*\[(?:polisher|writer|reviser|reviewer)-note\]\s*/i.test(line)
+    && !/^\s*\[(?:润色|写作|修订|审稿)备注\]\s*/.test(line)
+  );
+  return filtered.join("\n");
+}
+
 interface ParagraphShape {
   readonly paragraphs: ReadonlyArray<string>;
   readonly shortThreshold: number;

+ 9 - 6
packages/core/src/agents/writer.ts

@@ -11,6 +11,7 @@ import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import {
   detectCrossChapterRepetition,
   detectParagraphLengthDrift,
+  normalizePostWriteSurface,
   validatePostWrite,
   type PostWriteViolation,
 } from "./post-write-validator.js";
@@ -358,12 +359,14 @@ export class WriterAgent extends BaseAgent {
       : [];
 
     // ── Post-write validation (regex + rule-based, zero LLM cost) ──
+    const surfaceNormalizedContent = normalizePostWriteSurface(creative.content, resolvedLanguage);
+    const surfaceNormalizedWordCount = countChapterLength(surfaceNormalizedContent, resolvedLengthSpec.countingMode);
     const ruleViolations = [
-      ...validatePostWrite(creative.content, genreProfile, bookRules, resolvedLanguage),
-      ...detectCrossChapterRepetition(creative.content, fingerprintChapters, resolvedLanguage),
-      ...detectParagraphLengthDrift(creative.content, fingerprintChapters, resolvedLanguage),
+      ...validatePostWrite(surfaceNormalizedContent, genreProfile, bookRules, resolvedLanguage),
+      ...detectCrossChapterRepetition(surfaceNormalizedContent, fingerprintChapters, resolvedLanguage),
+      ...detectParagraphLengthDrift(surfaceNormalizedContent, fingerprintChapters, resolvedLanguage),
     ];
-    const aiTellIssues = analyzeAITells(creative.content, resolvedLanguage).issues;
+    const aiTellIssues = analyzeAITells(surfaceNormalizedContent, resolvedLanguage).issues;
 
     const postWriteErrors = ruleViolations.filter(v => v.severity === "error");
     const postWriteWarnings = ruleViolations.filter(v => v.severity === "warning");
@@ -406,8 +409,8 @@ export class WriterAgent extends BaseAgent {
     return {
       chapterNumber,
       title: creative.title,
-      content: creative.content,
-      wordCount: creative.wordCount,
+      content: surfaceNormalizedContent,
+      wordCount: surfaceNormalizedWordCount,
       preWriteCheck: creative.preWriteCheck,
       postSettlement: settlement.postSettlement,
       runtimeStateDelta: resolvedRuntimeStateDelta,

+ 5 - 4
packages/core/src/pipeline/chapter-review-cycle.ts

@@ -88,6 +88,7 @@ export async function runChapterReviewCycle(params: {
     applied: boolean;
     tokenUsage?: ChapterReviewCycleUsage;
   }>;
+  readonly normalizePostWriteSurface?: (chapterContent: string) => string;
   readonly assertChapterContentNotEmpty: (content: string, stage: string) => void;
   readonly addUsage: (
     left: ChapterReviewCycleUsage,
@@ -142,8 +143,8 @@ export async function runChapterReviewCycle(params: {
   };
 
   const normalizedBeforeAudit = await normalizeUntilInRange(finalContent);
-  finalContent = normalizedBeforeAudit.content;
-  finalWordCount = normalizedBeforeAudit.wordCount;
+  finalContent = params.normalizePostWriteSurface?.(normalizedBeforeAudit.content) ?? normalizedBeforeAudit.content;
+  finalWordCount = countChapterLength(finalContent, params.lengthSpec.countingMode);
   normalizeApplied = normalizeApplied || normalizedBeforeAudit.applied;
   params.assertChapterContentNotEmpty(finalContent, "draft generation");
 
@@ -246,7 +247,7 @@ export async function runChapterReviewCycle(params: {
       }
 
       params.assertChapterContentNotEmpty(reviseOutput.revisedContent, `repair iteration ${iteration + 1}`);
-      const revisedContent = reviseOutput.revisedContent;
+      const revisedContent = params.normalizePostWriteSurface?.(reviseOutput.revisedContent) ?? reviseOutput.revisedContent;
       const revisedWordCount = countChapterLength(revisedContent, params.lengthSpec.countingMode);
 
       // Re-assess revised content. If REVISED_CONTENT drifted on length,
@@ -317,7 +318,7 @@ export async function runChapterReviewCycle(params: {
   return {
     finalContent,
     finalWordCount,
-    preAuditNormalizedWordCount: normalizedBeforeAudit.wordCount,
+    preAuditNormalizedWordCount: finalWordCount,
     revised: snapshots.length > 1 && finalContent !== params.initialOutput.content,
     auditResult: currentAudit.auditResult,
     totalUsage,

+ 12 - 6
packages/core/src/pipeline/runner.ts

@@ -612,6 +612,7 @@ export class PipelineRunner {
       `.tmp-book-create-${book.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
     );
     const stageLanguage = await this.resolveBookLanguage(book);
+    const effectiveExternalContext = options.externalContext ?? this.config.externalContext;
 
     this.logStage(stageLanguage, { zh: "生成基础设定", en: "generating foundation" });
     const { profile: gp } = await this.loadGenreProfile(book.genre);
@@ -620,7 +621,7 @@ export class PipelineRunner {
     const foundation = await this.generateAndReviewFoundation({
       generate: (reviewFeedback) => architect.generateFoundation(
         book,
-        options.externalContext ?? this.config.externalContext,
+        effectiveExternalContext,
         reviewFeedback,
       ),
       reviewer,
@@ -640,17 +641,17 @@ export class PipelineRunner {
         book.language ?? gp.language,
       );
 
-      if (this.config.externalContext && this.config.externalContext.trim().length > 0) {
+      if (effectiveExternalContext && effectiveExternalContext.trim().length > 0) {
         const storyDir = join(stagingBookDir, "story");
         await mkdir(storyDir, { recursive: true });
-        await writeFile(join(storyDir, "brief.md"), this.config.externalContext, "utf-8");
+        await writeFile(join(storyDir, "brief.md"), effectiveExternalContext, "utf-8");
       }
 
       this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" });
       await this.state.ensureControlDocumentsAt(
         stagingBookDir,
         book.language ?? gp.language,
-        options.authorIntent ?? this.config.externalContext,
+        options.authorIntent ?? effectiveExternalContext,
       );
       if (options.currentFocus?.trim()) {
         await writeFile(
@@ -1523,7 +1524,10 @@ export class PipelineRunner {
       wordCount ?? book.chapterWordCount,
       pipelineLang,
     );
-    const { validatePostWrite: postWriteValidate } = await import("../agents/post-write-validator.js");
+    const {
+      normalizePostWriteSurface,
+      validatePostWrite: postWriteValidate,
+    } = await import("../agents/post-write-validator.js");
     const { validateHookLedger } = await import("../utils/hook-ledger-validator.js");
     const { readBookRules } = await import("../agents/rules-reader.js");
     const parsedBookRules = (await readBookRules(bookDir))?.rules ?? null;
@@ -1562,6 +1566,8 @@ export class PipelineRunner {
         lengthSpec,
         chapterIntent: writeInput.chapterIntent,
       }),
+      normalizePostWriteSurface: (chapterContent) =>
+        normalizePostWriteSurface(chapterContent, pipelineLang),
       assertChapterContentNotEmpty: (content, stage) =>
         this.assertChapterContentNotEmpty(content, chapterNumber, stage),
       addUsage: PipelineRunner.addUsage,
@@ -1613,7 +1619,7 @@ export class PipelineRunner {
         });
         totalUsage = PipelineRunner.addUsage(totalUsage, polishOutput.tokenUsage);
         if (polishOutput.changed && polishOutput.polishedContent.trim().length > 0) {
-          finalContent = polishOutput.polishedContent;
+          finalContent = normalizePostWriteSurface(polishOutput.polishedContent, pipelineLang);
           finalWordCount = countChapterLength(finalContent, lengthSpec.countingMode);
         }
       } catch (error) {