ソースを参照

feat(stage-d): wire post-write check into chat-panel onDone

在 chat-panel finishAgentSession 块中接入写后剧情自检:仅对 write_chapter/continue_chapter 触发 runPostWriteCheck;排除 chapter_plan 标记与空内容;结果写入 contextTrace.contextInfo.postWriteCheck;不阻止保存。补充独立函数测试 6 项,集成测试 8 项。
Mochocyang 2 ヶ月 前
親
コミット
788e05a998

+ 68 - 0
src/components/chat/chat-panel.spec.tsx

@@ -259,3 +259,71 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
     expect(source).toContain('closeChapterPlanDialog({ modify:')
   })
 })
+
+describe("chat-panel post-write check integration (Stage D)", () => {
+  it("imports runPostWriteCheck from the post-write-check-plugin", () => {
+    expect(source).toContain('import { runPostWriteCheck } from "@/lib/agent/plugins/post-write-check-plugin"')
+  })
+
+  it("declares a Stage D block inside the trace-building section", () => {
+    expect(source).toContain("=== Stage D: 写后剧情自检 ===")
+  })
+
+  it("scopes the check to write_chapter and continue_chapter tasks only", () => {
+    const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
+    expect(stageDIndex).toBeGreaterThan(-1)
+    const stageDBlock = source.slice(stageDIndex, stageDIndex + 800)
+    expect(stageDBlock).toContain('effectiveTaskRoute.intent === "write_chapter"')
+    expect(stageDBlock).toContain('effectiveTaskRoute.intent === "continue_chapter"')
+    // 不应对 rewrite_chapter 或其他意图触发
+    expect(stageDBlock).not.toContain("rewrite_chapter")
+  })
+
+  it("reads the final assistant content from the store (same as Stage C)", () => {
+    const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
+    const stageDBlock = source.slice(stageDIndex, stageDIndex + 800)
+    expect(stageDBlock).toContain("useChatStore.getState()")
+    expect(stageDBlock).toContain("lastAssistant")
+  })
+
+  it("excludes content carrying the chapter_plan marker", () => {
+    const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
+    const stageDBlock = source.slice(stageDIndex, stageDIndex + 800)
+    expect(stageDBlock).toContain('chapterContent.includes("chapter_plan")')
+    expect(stageDBlock).toContain("hasChapterPlanMarker")
+  })
+
+  it("skips empty content to avoid false reports", () => {
+    const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
+    const stageDBlock = source.slice(stageDIndex, stageDIndex + 1200)
+    expect(stageDBlock).toContain("if (chapterContent && !hasChapterPlanMarker)")
+  })
+
+  it("writes the check result into contextTrace.contextInfo.postWriteCheck", () => {
+    const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
+    const stageDBlock = source.slice(stageDIndex, stageDIndex + 1200)
+    expect(stageDBlock).toContain("runPostWriteCheck(chapterContent)")
+    expect(stageDBlock).toContain("postWriteCheck")
+    expect(stageDBlock).toContain("setContextInfo(contextTrace,")
+  })
+
+  it("places Stage D after result protocol and before finishTrace", () => {
+    const traceBlockIndex = source.indexOf("if (contextTrace && effectiveTaskRoute) {")
+    expect(traceBlockIndex).toBeGreaterThan(-1)
+    const traceBlock = source.slice(traceBlockIndex, traceBlockIndex + 2000)
+    const protocolIndex = traceBlock.indexOf("buildResultProtocolTrace")
+    const stageDIndex = traceBlock.indexOf("=== Stage D: 写后剧情自检 ===")
+    const finishIndex = traceBlock.indexOf('finishTrace(contextTrace, "done")')
+    expect(protocolIndex).toBeGreaterThan(-1)
+    expect(stageDIndex).toBeGreaterThan(protocolIndex)
+    expect(finishIndex).toBeGreaterThan(stageDIndex)
+  })
+
+  it("does not block saving: the check only writes to contextTrace, no return or throw", () => {
+    const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
+    const stageDBlock = source.slice(stageDIndex, stageDIndex + 800)
+    // 自检结果仅展示,不应阻止后续保存流程
+    expect(stageDBlock).not.toContain("throw ")
+    expect(stageDBlock).not.toContain("return")
+  })
+})

+ 23 - 0
src/components/chat/chat-panel.tsx

@@ -92,6 +92,7 @@ import { settleRunningAgentToolCalls } from "@/lib/agent/tool-events"
 import { appendMcpCallTrace } from "@/lib/agent/mcp-trace"
 import { runNovelPrePluginChain } from "@/lib/agent/novel-pre-plugin-chain"
 import { buildInitialContextTraceInfo } from "@/lib/agent/context-trace-builders"
+import { runPostWriteCheck } from "@/lib/agent/plugins/post-write-check-plugin"
 import { buildSelectedSkillsPrompt } from "@/lib/agent/plugins/select-skills-plugin"
 import { buildResultProtocolTrace } from "@/lib/novel/result-parser"
 import { validateChapterBeforeSave } from "@/lib/novel/result-save-guard"
@@ -1113,6 +1114,28 @@ export function ChatPanel() {
                 const protocolTrace = buildResultProtocolTrace("chapter", finalContent)
                 contextTrace = setContextInfo(contextTrace, { ...traceInfo, resultProtocol: protocolTrace })
               }
+              // === Stage D: 写后剧情自检 ===
+              // 仅对 write_chapter / continue_chapter 任务触发,避免对普通对话误触发
+              if (
+                effectiveTaskRoute.intent === "write_chapter" ||
+                effectiveTaskRoute.intent === "continue_chapter"
+              ) {
+                // 与 Stage C 一致:从 store 读取最终内容(闭包 assistantMessage 不会随流式更新)
+                const storeState = useChatStore.getState()
+                const lastAssistant = storeState.messages.find(
+                  (m) => m.id === assistantMessage.id && m.role === "assistant",
+                )
+                const chapterContent = lastAssistant?.content ?? ""
+                // 排除含 chapter_plan 标记的内容(计划本身不是正文)与空内容
+                const hasChapterPlanMarker = chapterContent.includes("chapter_plan")
+                if (chapterContent && !hasChapterPlanMarker) {
+                  const postWriteCheck = runPostWriteCheck(chapterContent)
+                  contextTrace = setContextInfo(contextTrace, {
+                    ...contextTrace.contextInfo!,
+                    postWriteCheck,
+                  })
+                }
+              }
               contextTrace = finishTrace(contextTrace, "done")
             }
             markDone(record)

+ 103 - 0
src/lib/agent/plugins/post-write-check-plugin.spec.ts

@@ -0,0 +1,103 @@
+import { describe, it, expect } from "vitest"
+import { createPostWriteCheckPlugin, runPostWriteCheck } from "./post-write-check-plugin"
+import type { PrePluginInput } from "../pipeline"
+
+describe("createPostWriteCheckPlugin", () => {
+  it("should return empty output when no content is available", async () => {
+    const plugin = createPostWriteCheckPlugin()
+    const result = await plugin.run({} as PrePluginInput)
+    expect(result).toEqual({})
+  })
+
+  it("should perform checks when content is provided via deps", async () => {
+    const plugin = createPostWriteCheckPlugin({ chapterContent: "test content with enough length and, however, some transition words. But suddenly something happened." })
+    const result = await plugin.run({} as PrePluginInput)
+    expect(result.postWriteCheck).toBeDefined()
+    expect((result as any).postWriteCheck!.items.length).toBe(7)
+    expect((result as any).postWriteCheck!.passedCount).toBeGreaterThan(0)
+  })
+
+  it("should detect empty content as failing certain checks", async () => {
+    const plugin = createPostWriteCheckPlugin({ chapterContent: "short" })
+    const result = await plugin.run({} as PrePluginInput)
+    expect(result.postWriteCheck).toBeDefined()
+    expect((result as any).postWriteCheck!.allPassed).toBe(false)
+  })
+
+  it("should detect placeholder markers", async () => {
+    const plugin = createPostWriteCheckPlugin({ chapterContent: "这是一章内容。其中【待补充】了部分细节。" })
+    const result = await plugin.run({} as PrePluginInput)
+    const mainLineItem = (result as any).postWriteCheck!.items.find((i: any) => i.name === "主线推进")
+    expect(mainLineItem?.passed).toBe(false)
+  })
+})
+
+describe("runPostWriteCheck", () => {
+  it("should return PostWriteCheck for any content", () => {
+    const result = runPostWriteCheck("Some content with transitions. However, something happened. But then...")
+    expect(result.items.length).toBe(7)
+    expect(result.passedCount).toBeGreaterThan(0)
+    expect(result.totalCount).toBe(7)
+  })
+
+  it("should detect all-pass for rich content", () => {
+    const result = runPostWriteCheck("第一章内容开始了。但是遇到了困难。然而主人公没有放弃。突然出现转机。却引发了新的问题。这是一段足够长的文本来确保所有检查通过。人物动机明确,冲突激烈,节奏紧凑。这篇内容足够复杂,包含了足够多的文字和情感深度。角色之间的对话生动有趣,情节推进自然流畅。每一个细节都经过精心打磨,力求给读者最完美的阅读体验。作者在写作过程中不断调整节奏,确保故事既有张力又不失细腻。人物性格鲜明,彼此之间的互动充满戏剧性。")
+    expect(result.allPassed).toBe(true)
+  })
+
+  it("空内容不会全部通过,但仍返回 7 项结构", () => {
+    const result = runPostWriteCheck("")
+    expect(result.totalCount).toBe(7)
+    expect(result.items.length).toBe(7)
+    expect(result.allPassed).toBe(false)
+    expect(result.passedCount).toBeLessThan(result.totalCount)
+  })
+
+  it("包含 chapter_plan 标记的内容仍返回 7 项结构(不触发误报)", () => {
+    const content = "<!-- chapter_plan -->计划<!-- /chapter_plan -->"
+    const result = runPostWriteCheck(content)
+    expect(result.totalCount).toBe(7)
+    expect(result.items.length).toBe(7)
+  })
+
+  it("合格的长正文大部分通过", () => {
+    const content = [
+      "主角推开门,然而屋内空无一人。",
+      "他突然意识到自己被追踪了,却发现手机没信号。",
+      "但是,桌上的一张纸条改变了一切。",
+      "他决定先离开,再想办法联系队友。",
+      "夜色已深,街道上只有零星的路灯。",
+    ].join("\n")
+    const result = runPostWriteCheck(content)
+    expect(result.totalCount).toBe(7)
+    expect(result.passedCount).toBeGreaterThanOrEqual(4)
+  })
+
+  it("返回的每项都有 name/passed/detail 字段且类型正确", () => {
+    const result = runPostWriteCheck("短内容")
+    for (const item of result.items) {
+      expect(item).toHaveProperty("name")
+      expect(item).toHaveProperty("passed")
+      expect(item).toHaveProperty("detail")
+      expect(typeof item.name).toBe("string")
+      expect(typeof item.passed).toBe("boolean")
+      expect(typeof item.detail).toBe("string")
+    }
+  })
+
+  it("passedCount 等于 passed=true 的项数", () => {
+    const result = runPostWriteCheck("测试内容但是有转折词")
+    const manualCount = result.items.filter((i) => i.passed).length
+    expect(result.passedCount).toBe(manualCount)
+  })
+
+  it("allPassed 在全部通过时为 true,否则为 false", () => {
+    const content = "a".repeat(300) + " 但是然而突然却 " + "b".repeat(300)
+    const result = runPostWriteCheck(content)
+    if (result.passedCount === result.totalCount) {
+      expect(result.allPassed).toBe(true)
+    } else {
+      expect(result.allPassed).toBe(false)
+    }
+  })
+})