Explorar o código

feat(skills): add draft-review-skill with loadReviewEvidence

- Add type definitions: Deviation, DraftReviewInput, DraftReviewResult, ReviewEvidence
- Implement loadReviewEvidence function that aggregates cognition, character states,
  foreshadowing, and previous chapter snapshot into a single ReviewEvidence struct
- Handle read errors gracefully with rawLoadError flag
- Add 3 test cases covering empty data, successful load, and error handling
Mochocyang hai 2 meses
pai
achega
674186b7df

+ 100 - 0
src/lib/agent/skills/draft-review-skill.spec.ts

@@ -0,0 +1,100 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+  loadReviewEvidence,
+  type Deviation,
+  type DraftReviewInput,
+  type DraftReviewResult,
+  type ReviewEvidence,
+} from "./draft-review-skill";
+
+vi.mock("@/lib/novel/character-cognition", () => ({
+  loadCognitionState: vi.fn(),
+  emptyCognitionState: vi.fn(() => ({
+    characters: [],
+    readerKnows: [],
+    lastUpdatedChapter: 0,
+  })),
+}));
+
+vi.mock("@/lib/novel/character-state", () => ({
+  loadCharacterStates: vi.fn(),
+  createEmptyCharacterStateStore: vi.fn(() => ({
+    characters: [],
+    lastUpdated: "2026-07-05T00:00:00.000Z",
+  })),
+}));
+
+vi.mock("@/lib/novel/foreshadowing-tracker", () => ({
+  loadForeshadowingTracker: vi.fn(),
+  createEmptyForeshadowingStore: vi.fn(() => ({
+    items: [],
+    lastUpdated: "2026-07-05T00:00:00.000Z",
+  })),
+}));
+
+vi.mock("@/lib/novel/chapter-ingest", () => ({
+  listSnapshots: vi.fn(() => Promise.resolve([])),
+  loadSnapshot: vi.fn(),
+}));
+
+vi.mock("@/commands/fs", () => ({
+  readFile: vi.fn(),
+  fileExists: vi.fn(),
+  writeFileAtomic: vi.fn(),
+  createDirectory: vi.fn(),
+}));
+
+describe("loadReviewEvidence", () => {
+  beforeEach(() => vi.clearAllMocks());
+
+  it("返回空真源当记忆中心没有任何派生数据", async () => {
+    const { loadCognitionState } =
+      await import("@/lib/novel/character-cognition");
+    const { loadCharacterStates } = await import("@/lib/novel/character-state");
+    const { loadForeshadowingTracker } =
+      await import("@/lib/novel/foreshadowing-tracker");
+    const { listSnapshots, loadSnapshot } =
+      await import("@/lib/novel/chapter-ingest");
+    vi.mocked(loadCognitionState).mockResolvedValueOnce(null);
+    vi.mocked(loadCharacterStates).mockResolvedValueOnce({
+      characters: [],
+      lastUpdated: "",
+    });
+    vi.mocked(loadForeshadowingTracker).mockResolvedValueOnce({
+      items: [],
+      lastUpdated: "",
+    });
+    vi.mocked(listSnapshots).mockResolvedValueOnce([]);
+    vi.mocked(loadSnapshot).mockResolvedValueOnce(null);
+
+    const evidence = await loadReviewEvidence("/proj");
+    expect(evidence.cognition).toBeNull();
+    expect(evidence.characterStates.characters).toHaveLength(0);
+    expect(evidence.foreshadowing.items).toHaveLength(0);
+    expect(evidence.previousSnapshot).toBeNull();
+    expect(evidence.internalConflict).toBe(false);
+    expect(evidence.rawLoadError).toBe(false);
+  });
+
+  it("当全部读取成功时聚合返回", async () => {
+    const { loadCognitionState } =
+      await import("@/lib/novel/character-cognition");
+    vi.mocked(loadCognitionState).mockResolvedValueOnce({
+      characters: [{ character: "李雷", knows: ["暗杀计划"], doesNotKnow: [] }],
+      readerKnows: [],
+      lastUpdatedChapter: 3,
+    });
+    const evidence = await loadReviewEvidence("/proj");
+    expect(evidence.cognition?.characters[0].character).toBe("李雷");
+    expect(evidence.rawLoadError).toBe(false);
+  });
+
+  it("读取异常时 rawLoadError=true 但不抛出", async () => {
+    const { loadCognitionState } =
+      await import("@/lib/novel/character-cognition");
+    vi.mocked(loadCognitionState).mockRejectedValueOnce(new Error("文件损坏"));
+    const evidence = await loadReviewEvidence("/proj");
+    expect(evidence.rawLoadError).toBe(true);
+    expect(evidence.cognition).toBeNull();
+  });
+});

+ 62 - 0
src/lib/agent/skills/draft-review-skill.ts

@@ -0,0 +1,62 @@
+import { loadCognitionState, type CognitionState } from "@/lib/novel/character-cognition"
+import { loadCharacterStates, type CharacterStateStore } from "@/lib/novel/character-state"
+import { loadForeshadowingTracker, type ForeshadowingStore } from "@/lib/novel/foreshadowing-tracker"
+import { listSnapshots, loadSnapshot, type ChapterSnapshot } from "@/lib/novel/chapter-ingest"
+
+export type DeviationType = "cognition" | "state" | "continuity" | "foreshadowing"
+export type DeviationSeverity = "high" | "mid" | "low"
+
+export interface Deviation {
+  id: string
+  type: DeviationType
+  location: string
+  originalText: string
+  expected: string
+  memoryEvidence: string
+  severity: DeviationSeverity
+  repairAction?: string
+}
+
+export interface DraftReviewInput {
+  projectPath: string
+  draftChapterText: string
+  draftChapterNumber: number
+  mode: "full" | "incremental"
+  previousRound?: DraftReviewResult
+}
+
+export interface DraftReviewResult {
+  deviations: Deviation[]
+  revisedDraft: string
+  repairSummary: string
+  retryRound: number
+  truncated: boolean
+}
+
+export interface ReviewEvidence {
+  cognition: CognitionState | null
+  characterStates: CharacterStateStore
+  foreshadowing: ForeshadowingStore
+  previousSnapshot: ChapterSnapshot | null
+  internalConflict: boolean
+  rawLoadError: boolean
+}
+
+export async function loadReviewEvidence(projectPath: string): Promise<ReviewEvidence> {
+  let cognition: CognitionState | null = null
+  let characterStates: CharacterStateStore = { characters: [], lastUpdated: "" }
+  let foreshadowing: ForeshadowingStore = { items: [], lastUpdated: "" }
+  let previousSnapshot: ChapterSnapshot | null = null
+  let rawLoadError = false
+
+  try { cognition = await loadCognitionState(projectPath) } catch { rawLoadError = true }
+  try { characterStates = await loadCharacterStates(projectPath) } catch { rawLoadError = true }
+  try { foreshadowing = await loadForeshadowingTracker(projectPath) } catch { rawLoadError = true }
+  try {
+    const numbers = await listSnapshots(projectPath)
+    const valid = numbers.filter((n) => n > 0)
+    if (valid.length > 0) previousSnapshot = await loadSnapshot(projectPath, Math.max(...valid))
+  } catch { rawLoadError = true }
+
+  return { cognition, characterStates, foreshadowing, previousSnapshot, internalConflict: false, rawLoadError }
+}