Просмотр исходного кода

refactor(pipeline): extract chapter persistence tail

Ma 7 месяцев назад
Родитель
Сommit
f989204e6c

+ 152 - 0
packages/core/src/__tests__/chapter-persistence.test.ts

@@ -0,0 +1,152 @@
+import { describe, expect, it, vi } from "vitest";
+import type { AuditIssue, AuditResult } from "../agents/continuity.js";
+import type { ChapterMeta } from "../models/chapter.js";
+import { persistChapterArtifacts } from "../pipeline/chapter-persistence.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+function createIssue(overrides?: Partial<AuditIssue>): AuditIssue {
+  return {
+    severity: "warning",
+    category: "continuity",
+    description: "issue",
+    suggestion: "fix",
+    ...overrides,
+  };
+}
+
+function createAuditResult(overrides?: Partial<AuditResult>): AuditResult {
+  return {
+    passed: true,
+    issues: [],
+    summary: "clean",
+    ...overrides,
+  };
+}
+
+describe("persistChapterArtifacts", () => {
+  it("persists truth files, index, drift guidance, and snapshots for reviewable chapters", async () => {
+    const saveChapter = vi.fn().mockResolvedValue(undefined);
+    const saveTruthFiles = vi.fn().mockResolvedValue(undefined);
+    const saveChapterIndex = vi.fn().mockResolvedValue(undefined);
+    const markBookActiveIfNeeded = vi.fn().mockResolvedValue(undefined);
+    const persistAuditDriftGuidance = vi.fn().mockResolvedValue(undefined);
+    const snapshotState = vi.fn().mockResolvedValue(undefined);
+    const syncCurrentStateFactHistory = vi.fn().mockResolvedValue(undefined);
+    const logSnapshotStage = vi.fn();
+
+    await persistChapterArtifacts({
+      chapterNumber: 3,
+      chapterTitle: "Chapter Title",
+      status: "ready-for-review",
+      auditResult: createAuditResult({
+        issues: [
+          createIssue({ severity: "info", description: "ignore me" }),
+          createIssue({ severity: "warning", description: "keep me" }),
+          createIssue({ severity: "critical", description: "keep me too" }),
+        ],
+      }),
+      finalWordCount: 888,
+      lengthWarnings: ["warn"],
+      degradedIssues: [],
+      tokenUsage: ZERO_USAGE,
+      loadChapterIndex: async () => [] satisfies ReadonlyArray<ChapterMeta>,
+      saveChapter,
+      saveTruthFiles,
+      saveChapterIndex,
+      markBookActiveIfNeeded,
+      persistAuditDriftGuidance,
+      snapshotState,
+      syncCurrentStateFactHistory,
+      logSnapshotStage,
+      now: () => "2026-04-01T00:00:00.000Z",
+    });
+
+    expect(saveChapter).toHaveBeenCalledTimes(1);
+    expect(saveTruthFiles).toHaveBeenCalledTimes(1);
+    expect(saveChapterIndex).toHaveBeenCalledWith([
+      expect.objectContaining({
+        number: 3,
+        title: "Chapter Title",
+        status: "ready-for-review",
+        wordCount: 888,
+        auditIssues: [
+          "[info] ignore me",
+          "[warning] keep me",
+          "[critical] keep me too",
+        ],
+        reviewNote: undefined,
+        tokenUsage: ZERO_USAGE,
+      }),
+    ]);
+    expect(markBookActiveIfNeeded).toHaveBeenCalledTimes(1);
+    expect(persistAuditDriftGuidance).toHaveBeenCalledWith([
+      expect.objectContaining({ severity: "warning", description: "keep me" }),
+      expect.objectContaining({ severity: "critical", description: "keep me too" }),
+    ]);
+    expect(logSnapshotStage).toHaveBeenCalledTimes(1);
+    expect(snapshotState).toHaveBeenCalledTimes(1);
+    expect(syncCurrentStateFactHistory).toHaveBeenCalledTimes(1);
+  });
+
+  it("skips truth persistence and snapshots for state-degraded chapters while preserving review note", async () => {
+    const saveChapter = vi.fn().mockResolvedValue(undefined);
+    const saveTruthFiles = vi.fn().mockResolvedValue(undefined);
+    const saveChapterIndex = vi.fn().mockResolvedValue(undefined);
+    const markBookActiveIfNeeded = vi.fn().mockResolvedValue(undefined);
+    const persistAuditDriftGuidance = vi.fn().mockResolvedValue(undefined);
+    const snapshotState = vi.fn().mockResolvedValue(undefined);
+    const syncCurrentStateFactHistory = vi.fn().mockResolvedValue(undefined);
+    const logSnapshotStage = vi.fn();
+
+    await persistChapterArtifacts({
+      chapterNumber: 4,
+      chapterTitle: "Degraded Chapter",
+      status: "state-degraded",
+      auditResult: createAuditResult({
+        passed: false,
+        issues: [createIssue({ description: "audit issue" })],
+        summary: "needs review",
+      }),
+      finalWordCount: 512,
+      lengthWarnings: [],
+      degradedIssues: [createIssue({ description: "state mismatch" })],
+      tokenUsage: ZERO_USAGE,
+      loadChapterIndex: async () => [] satisfies ReadonlyArray<ChapterMeta>,
+      saveChapter,
+      saveTruthFiles,
+      saveChapterIndex,
+      markBookActiveIfNeeded,
+      persistAuditDriftGuidance,
+      snapshotState,
+      syncCurrentStateFactHistory,
+      logSnapshotStage,
+      now: () => "2026-04-01T00:00:00.000Z",
+    });
+
+    expect(saveChapter).toHaveBeenCalledTimes(1);
+    expect(saveTruthFiles).not.toHaveBeenCalled();
+    expect(saveChapterIndex).toHaveBeenCalledWith([
+      expect.objectContaining({
+        number: 4,
+        title: "Degraded Chapter",
+        status: "state-degraded",
+        reviewNote: expect.any(String),
+      }),
+    ]);
+    const reviewNote = saveChapterIndex.mock.calls[0]?.[0]?.[0]?.reviewNote as string;
+    expect(JSON.parse(reviewNote)).toMatchObject({
+      kind: "state-degraded",
+      baseStatus: "audit-failed",
+      injectedIssues: ["[warning] state mismatch"],
+    });
+    expect(persistAuditDriftGuidance).toHaveBeenCalledWith([]);
+    expect(logSnapshotStage).not.toHaveBeenCalled();
+    expect(snapshotState).not.toHaveBeenCalled();
+    expect(syncCurrentStateFactHistory).not.toHaveBeenCalled();
+  });
+});

+ 75 - 0
packages/core/src/pipeline/chapter-persistence.ts

@@ -0,0 +1,75 @@
+import type { AuditIssue, AuditResult } from "../agents/continuity.js";
+import type { ChapterMeta } from "../models/chapter.js";
+import type { LengthTelemetry } from "../models/length-governance.js";
+import { buildStateDegradedReviewNote } from "./chapter-state-recovery.js";
+
+export interface ChapterPersistenceUsage {
+  readonly promptTokens: number;
+  readonly completionTokens: number;
+  readonly totalTokens: number;
+}
+
+export type ChapterPersistenceStatus = "ready-for-review" | "audit-failed" | "state-degraded";
+
+export async function persistChapterArtifacts(params: {
+  readonly chapterNumber: number;
+  readonly chapterTitle: string;
+  readonly status: ChapterPersistenceStatus;
+  readonly auditResult: AuditResult;
+  readonly finalWordCount: number;
+  readonly lengthWarnings: ReadonlyArray<string>;
+  readonly lengthTelemetry?: LengthTelemetry;
+  readonly degradedIssues: ReadonlyArray<AuditIssue>;
+  readonly tokenUsage?: ChapterPersistenceUsage;
+  readonly loadChapterIndex: () => Promise<ReadonlyArray<ChapterMeta>>;
+  readonly saveChapter: () => Promise<void>;
+  readonly saveTruthFiles: () => Promise<void>;
+  readonly saveChapterIndex: (index: ReadonlyArray<ChapterMeta>) => Promise<void>;
+  readonly markBookActiveIfNeeded: () => Promise<void>;
+  readonly persistAuditDriftGuidance: (issues: ReadonlyArray<AuditIssue>) => Promise<void>;
+  readonly snapshotState: () => Promise<void>;
+  readonly syncCurrentStateFactHistory: () => Promise<void>;
+  readonly logSnapshotStage: () => void;
+  readonly now?: () => string;
+}): Promise<{ readonly entry: ChapterMeta }> {
+  await params.saveChapter();
+  if (params.status !== "state-degraded") {
+    await params.saveTruthFiles();
+  }
+
+  const existingIndex = await params.loadChapterIndex();
+  const now = params.now?.() ?? new Date().toISOString();
+  const entry: ChapterMeta = {
+    number: params.chapterNumber,
+    title: params.chapterTitle,
+    status: params.status,
+    wordCount: params.finalWordCount,
+    createdAt: now,
+    updatedAt: now,
+    auditIssues: params.auditResult.issues.map((issue) => `[${issue.severity}] ${issue.description}`),
+    lengthWarnings: [...params.lengthWarnings],
+    reviewNote: params.status === "state-degraded"
+      ? buildStateDegradedReviewNote(
+          params.auditResult.passed ? "ready-for-review" : "audit-failed",
+          params.degradedIssues,
+        )
+      : undefined,
+    lengthTelemetry: params.lengthTelemetry,
+    tokenUsage: params.tokenUsage,
+  };
+  await params.saveChapterIndex([...existingIndex, entry]);
+  await params.markBookActiveIfNeeded();
+
+  const driftIssues = params.auditResult.issues.filter(
+    (issue) => issue.severity === "critical" || issue.severity === "warning",
+  );
+  await params.persistAuditDriftGuidance(params.status === "state-degraded" ? [] : driftIssues);
+
+  if (params.status !== "state-degraded") {
+    params.logSnapshotStage();
+    await params.snapshotState();
+    await params.syncCurrentStateFactHistory();
+  }
+
+  return { entry };
+}

+ 34 - 53
packages/core/src/pipeline/runner.ts

@@ -36,11 +36,11 @@ import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/p
 import { join } from "node:path";
 import {
   buildStateDegradedPersistenceOutput,
-  buildStateDegradedReviewNote,
   parseStateDegradedReviewNote,
   resolveStateDegradedBaseStatus,
   retrySettlementAfterValidationFailure,
 } from "./chapter-state-recovery.js";
+import { persistChapterArtifacts } from "./chapter-persistence.js";
 import { runChapterReviewCycle } from "./chapter-review-cycle.js";
 import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js";
 
@@ -1268,61 +1268,42 @@ export class PipelineRunner {
       }
     }
 
-    await writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang);
-    if (chapterStatus !== "state-degraded") {
-      await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang);
-      await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput);
-      this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" });
-      await this.syncNarrativeMemoryIndex(bookId);
-    }
-
-    // 5. Update chapter index
-    const existingIndex = await this.state.loadChapterIndex(bookId);
-    const now = new Date().toISOString();
-    const newEntry: ChapterMeta = {
-      number: chapterNumber,
-      title: persistenceOutput.title,
-      status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"),
-      wordCount: finalWordCount,
-      createdAt: now,
-      updatedAt: now,
-      auditIssues: auditResult.issues.map(
-        (i) => `[${i.severity}] ${i.description}`,
-      ),
+    const resolvedStatus = chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed");
+    await persistChapterArtifacts({
+      chapterNumber,
+      chapterTitle: persistenceOutput.title,
+      status: resolvedStatus,
+      auditResult,
+      finalWordCount,
       lengthWarnings,
-      reviewNote: chapterStatus === "state-degraded"
-        ? buildStateDegradedReviewNote(
-            auditResult.passed ? "ready-for-review" : "audit-failed",
-            degradedIssues,
-          )
-        : undefined,
       lengthTelemetry,
+      degradedIssues,
       tokenUsage: totalUsage,
-    };
-    await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
-    await this.markBookActiveIfNeeded(bookId);
-
-    // 5.5 Persist audit drift guidance without polluting current_state.md
-    const driftIssues = auditResult.issues.filter(
-      (i) => i.severity === "critical" || i.severity === "warning",
-    );
-    await this.persistAuditDriftGuidance({
-      bookDir,
-      chapterNumber,
-      issues: chapterStatus === "state-degraded" ? [] : driftIssues,
-      language: stageLanguage,
-    }).catch(() => undefined);
-
-    // 5.6 Snapshot state for rollback support
-    if (chapterStatus !== "state-degraded") {
-      this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" });
-      await this.state.snapshotState(bookId, chapterNumber);
-      await this.syncCurrentStateFactHistory(bookId, chapterNumber);
-    }
+      loadChapterIndex: () => this.state.loadChapterIndex(bookId),
+      saveChapter: () => writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang),
+      saveTruthFiles: async () => {
+        await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang);
+        await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput);
+        this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" });
+        await this.syncNarrativeMemoryIndex(bookId);
+      },
+      saveChapterIndex: (index) => this.state.saveChapterIndex(bookId, index),
+      markBookActiveIfNeeded: () => this.markBookActiveIfNeeded(bookId),
+      persistAuditDriftGuidance: (issues) => this.persistAuditDriftGuidance({
+        bookDir,
+        chapterNumber,
+        issues,
+        language: stageLanguage,
+      }).catch(() => undefined),
+      snapshotState: () => this.state.snapshotState(bookId, chapterNumber),
+      syncCurrentStateFactHistory: () => this.syncCurrentStateFactHistory(bookId, chapterNumber),
+      logSnapshotStage: () =>
+        this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" }),
+    });
 
     // 6. Send notification
     if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
-      const statusEmoji = chapterStatus === "state-degraded"
+      const statusEmoji = resolvedStatus === "state-degraded"
         ? "🧯"
         : auditResult.passed ? "✅" : "⚠️";
       const chapterLength = formatLengthCount(finalWordCount, lengthSpec.countingMode);
@@ -1331,7 +1312,7 @@ export class PipelineRunner {
         body: [
           `**${persistenceOutput.title}** | ${chapterLength}`,
           revised ? "📝 已自动修正" : "",
-          chapterStatus === "state-degraded"
+          resolvedStatus === "state-degraded"
             ? "状态结算: 已降级保存,需先修复 state 再继续"
             : `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`,
           ...auditResult.issues
@@ -1348,7 +1329,7 @@ export class PipelineRunner {
       wordCount: finalWordCount,
       passed: auditResult.passed,
       revised,
-      status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"),
+      status: resolvedStatus,
     });
 
     return {
@@ -1357,7 +1338,7 @@ export class PipelineRunner {
       wordCount: finalWordCount,
       auditResult,
       revised,
-      status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"),
+      status: resolvedStatus,
       lengthWarnings,
       lengthTelemetry,
       tokenUsage: totalUsage,