Bläddra i källkod

新增下一步推荐模块及测试,含正文生成禁止过滤

Mochocyang 3 månader sedan
förälder
incheckning
5cf965d07b
2 ändrade filer med 116 tillägg och 0 borttagningar
  1. 41 0
      src/lib/novel/outline-next-step.spec.ts
  2. 75 0
      src/lib/novel/outline-next-step.ts

+ 41 - 0
src/lib/novel/outline-next-step.spec.ts

@@ -0,0 +1,41 @@
+import { describe, it, expect } from "vitest"
+import { parseNextStep, isRecommendationForbidden } from "./outline-next-step"
+
+describe("parseNextStep", () => {
+  it("解析正常推荐", () => {
+    const text = `<!-- next_step -->
+{"completedModule":"章节细纲","completedScope":"第1-3章细纲","recommendations":[{"id":"A","label":"完善人物小传","reason":"提及3个未立传角色"},{"id":"D","label":"自定义","reason":"由你描述"}]}
+<!-- /next_step -->`
+    const result = parseNextStep(text)
+    expect(result).not.toBeNull()
+    expect(result!.completedModule).toBe("章节细纲")
+    expect(result!.recommendations).toHaveLength(2)
+  })
+
+  it("无标记块时返回 null", () => {
+    expect(parseNextStep("普通文本")).toBeNull()
+  })
+
+  it("过滤掉被禁止的正文生成推荐", () => {
+    const text = `<!-- next_step -->
+{"completedModule":"章节细纲","completedScope":"第1-3章细纲","recommendations":[{"id":"A","label":"生成对应章节正文","reason":"可以开始写正文了"},{"id":"B","label":"完善人物小传","reason":"提及3个未立传角色"}]}
+<!-- /next_step -->`
+    const result = parseNextStep(text)
+    expect(result).not.toBeNull()
+    expect(result!.recommendations).toHaveLength(1)
+    expect(result!.recommendations[0].label).toBe("完善人物小传")
+  })
+})
+
+describe("isRecommendationForbidden", () => {
+  it("禁止包含正文生成关键词", () => {
+    expect(isRecommendationForbidden("生成对应章节正文")).toBe(true)
+    expect(isRecommendationForbidden("写正文")).toBe(true)
+    expect(isRecommendationForbidden("生成章节内容")).toBe(true)
+  })
+
+  it("允许大纲体系内的推荐", () => {
+    expect(isRecommendationForbidden("完善人物小传")).toBe(false)
+    expect(isRecommendationForbidden("生成组织势力设定")).toBe(false)
+  })
+})

+ 75 - 0
src/lib/novel/outline-next-step.ts

@@ -0,0 +1,75 @@
+export interface NextStepRecommendationItem {
+  id: string
+  label: string
+  reason: string
+}
+
+export interface NextStepRecommendation {
+  completedModule: string
+  completedScope: string
+  recommendations: NextStepRecommendationItem[]
+}
+
+const NEXT_STEP_PATTERN = /<!--\s*next_step\s*-->([\s\S]*?)<!--\s*\/next_step\s*-->/i
+
+const FORBIDDEN_PATTERNS = [
+  /生成.*正文/,
+  /写.*正文/,
+  /生成.*章节内容/,
+  /生成.*正文章节/,
+  /章节正文/,
+  /写正文/,
+  /生成正文/,
+]
+
+export function isRecommendationForbidden(label: string): boolean {
+  return FORBIDDEN_PATTERNS.some((pattern) => pattern.test(label))
+}
+
+export function parseNextStep(text: string): NextStepRecommendation | null {
+  const match = text.match(NEXT_STEP_PATTERN)
+  if (!match) return null
+
+  let payload: unknown
+  try {
+    payload = JSON.parse(match[1].trim())
+  } catch {
+    return null
+  }
+
+  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null
+
+  const raw = payload as Record<string, unknown>
+
+  const recommendations: NextStepRecommendationItem[] = Array.isArray(raw.recommendations)
+    ? raw.recommendations
+        .filter((item): item is Record<string, unknown> =>
+          Boolean(item) && typeof item === "object" && !Array.isArray(item))
+        .map((item) => ({
+          id: String(item.id ?? ""),
+          label: String(item.label ?? ""),
+          reason: String(item.reason ?? ""),
+        }))
+        .filter((item) => item.id && item.label)
+        .filter((item) => !isRecommendationForbidden(item.label))
+    : []
+
+  return {
+    completedModule: String(raw.completedModule ?? ""),
+    completedScope: String(raw.completedScope ?? ""),
+    recommendations,
+  }
+}
+
+export function buildNextStepPromptSuffix(): string {
+  return [
+    "",
+    "## 下一步推荐输出要求",
+    "生成完成后,在回复末尾附加 <!-- next_step --> 标记块,按 JSON 输出。",
+    "字段:completedModule、completedScope、recommendations(数组,每项含 id、label、reason)。",
+    "推荐方向仅限大纲体系内:人物小传、组织势力设定、力量体系、金手指设定、背景设定、地理设定、伏笔计划、地点设定、章节细纲。",
+    "严禁推荐「生成章节正文」「写正文」等正文生成类操作。",
+    "推荐应由 AI 根据刚生成内容中提及但尚未建立的关联项动态推断。",
+    "必须包含一个 id 为 D 的「自定义」选项。",
+  ].join("\n")
+}