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

feat(story-simulation): 实现推演报告生成器

Mochocyang 3 месяцев назад
Родитель
Сommit
e1bbb7b94d
1 измененных файлов с 367 добавлено и 0 удалено
  1. 367 0
      src/lib/novel/story-simulation/simulation-report-agent.ts

+ 367 - 0
src/lib/novel/story-simulation/simulation-report-agent.ts

@@ -0,0 +1,367 @@
+import type { ChatMessage } from "@/lib/llm-client"
+import { streamChat } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type {
+  CharacterAnalysis,
+  SimulationEvent,
+  SimulationMode,
+  SimulationReport,
+  StoryBranch,
+  StoryFramework,
+} from "@/lib/novel/story-simulation/types"
+
+// ── 对外接口 ──
+
+export interface ReportGenerationOptions {
+  events: SimulationEvent[]
+  framework: StoryFramework
+  mode: SimulationMode
+  llmConfig: LlmConfig
+  onProgress?: (label: string) => void
+  signal?: AbortSignal
+}
+
+// ── 内部辅助:将 streamChat 的流式回调收拢为一个完整字符串 ──
+
+async function collectStream(
+  config: LlmConfig,
+  messages: ChatMessage[],
+  signal?: AbortSignal,
+): Promise<string> {
+  let result = ""
+  let streamError: Error | null = null
+
+  await streamChat(
+    config,
+    messages,
+    {
+      onToken: (token) => {
+        result += token
+      },
+      onDone: () => {},
+      onError: (err) => {
+        streamError = err
+      },
+    },
+    signal,
+  )
+
+  if (streamError) throw streamError
+  return result
+}
+
+// ── 内部辅助:从 LLM 文本中提取 JSON(支持裸 JSON 与代码块) ──
+
+function extractJson(text: string): string | null {
+  const trimmed = text.trim()
+
+  // 直接解析
+  try {
+    JSON.parse(trimmed)
+    return trimmed
+  } catch {
+    // 继续
+  }
+
+  // 从 markdown 代码块中提取
+  const codeBlockMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed)
+  if (codeBlockMatch) {
+    const candidate = codeBlockMatch[1].trim()
+    try {
+      JSON.parse(candidate)
+      return candidate
+    } catch {
+      // 继续
+    }
+  }
+
+  // 从文本中查找第一个 JSON 对象
+  const objMatch = /\{[\s\S]*\}/.exec(trimmed)
+  if (objMatch) {
+    const candidate = objMatch[0]
+    try {
+      JSON.parse(candidate)
+      return candidate
+    } catch {
+      // 继续
+    }
+  }
+
+  return null
+}
+
+// ── 内部辅助:将单个事件序列化为文本行 ──
+
+function formatEvent(event: SimulationEvent): string {
+  const time = event.timestamp
+
+  switch (event.type) {
+    case "node-start": {
+      const node = event.node
+      if (!node) return `[${time}] 节点开始(节点信息缺失)`
+      return `[${time}] 节点开始:第 ${node.index + 1} 个节点「${node.title}」(${node.phase}),核心冲突:${node.coreConflict},目标:${node.goal}`
+    }
+    case "node-complete": {
+      const node = event.node
+      if (!node) return `[${time}] 节点完成(节点信息缺失)`
+      return `[${time}] 节点完成:第 ${node.index + 1} 个节点「${node.title}」`
+    }
+    case "agent-action": {
+      const { agent, action, round, node, stateChanges } = event
+      const name = agent ? agent.name : "未知角色"
+      const nodeTitle = node ? node.title : ""
+      const roundLabel = round !== undefined ? `第 ${round + 1} 轮` : ""
+
+      let actionDesc = ""
+      if (action) {
+        switch (action.type) {
+          case "speak":
+            actionDesc = action.target
+              ? `对 ${action.target} 说:「${action.content}」`
+              : `自言自语:「${action.content}」`
+            break
+          case "act":
+            actionDesc = `行动:${action.content}`
+            break
+          case "react":
+            actionDesc = `对 ${action.target} 做出反应:${action.content}`
+            break
+          case "decide":
+            actionDesc = `做出决定:${action.content}`
+            break
+          case "investigate":
+            actionDesc = `调查:${action.content}`
+            break
+          case "conflict":
+            actionDesc = `与 ${action.target} 发生冲突:${action.content}`
+            break
+          case "cooperate":
+            actionDesc = `与 ${action.target} 合作:${action.content}`
+            break
+          case "withhold":
+            actionDesc = `隐瞒信息:${action.content}`
+            break
+        }
+      }
+
+      const changes =
+        stateChanges && stateChanges.length > 0
+          ? `;状态变更:${stateChanges.join(",")}`
+          : ""
+
+      return `[${time}] 角色行为:${name}(节点「${nodeTitle}」${roundLabel})${actionDesc}${changes}`
+    }
+    default:
+      return `[${time}] 未知事件`
+  }
+}
+
+function serializeEvents(events: SimulationEvent[]): string {
+  return events.map(formatEvent).join("\n")
+}
+
+// ── 内部辅助:构建系统提示词 ──
+
+function buildSystemPrompt(framework: StoryFramework, mode: SimulationMode): string {
+  return [
+    "你是一位资深的小说推演分析师。请基于仿真引擎产出的角色行为与节点事件记录,生成一份结构化的推演报告。",
+    "",
+    `当前故事框架:「${framework.title}」,前提:${framework.premise}`,
+    `仿真模式:${mode}`,
+    "",
+    "请严格按以下 JSON 结构输出报告,不要输出任何其他文字:",
+    "{",
+    '  "characterAnalyses": [',
+    "    {",
+    '      "characterId": "角色ID",',
+    '      "name": "角色名",',
+    '      "behaviors": [',
+    '        { "node": "所属节点标题", "action": "行为概述", "motivation": "动机说明" }',
+    "      ],",
+    '      "stateChanges": ["状态变更描述"],',
+    '      "consistencyScore": 0到100的整数,人设一致性评分',
+    "    }",
+    "  ],",
+    '  "branches": [',
+    "    {",
+    '      "title": "走向标题",',
+    '      "summary": "走向摘要",',
+    '      "keyEvents": ["关键事件"],',
+    '      "probability": "high | medium | low",',
+    '      "pros": "优势",',
+    '      "cons": "不足",',
+    '      "recommendation": true或false,是否推荐',
+    "    }",
+    "  ],",
+    '  "recommendation": "综合推荐建议"',
+    "}",
+    "",
+    "要求:",
+    "1. characterAnalyses:对每个出场角色分析其行为与动机,并给出 0-100 的人设一致性评分。",
+    "2. branches:给出 2-3 条可能的剧情走向,每条包含标题、摘要、关键事件、发生概率(高/中/低)、优势、不足、是否推荐。",
+    "3. recommendation:给出综合推荐建议。",
+    "4. 只输出 JSON 对象,不要包含 markdown 代码块标记或任何解释性文字。",
+  ].join("\n")
+}
+
+// ── 内部辅助:构建用户提示词 ──
+
+function buildUserPrompt(framework: StoryFramework, events: SimulationEvent[]): string {
+  const nodeLines = framework.nodes
+    .map(
+      (n) =>
+        `- 节点 ${n.index + 1}「${n.phase}」${n.title}:核心冲突「${n.coreConflict}」,目标「${n.goal}」`,
+    )
+    .join("\n")
+
+  const parts: string[] = [
+    "【故事框架】",
+    `标题:${framework.title}`,
+    `前提:${framework.premise}`,
+    `目标字数:${framework.targetWords}`,
+    `来源章节数:${framework.sourceChapters}`,
+  ]
+  if (framework.userIdea) {
+    parts.push(`用户构想:${framework.userIdea}`)
+  }
+  parts.push("故事节点:")
+  parts.push(nodeLines)
+  parts.push("")
+  parts.push("【仿真事件记录】")
+  parts.push(serializeEvents(events))
+  parts.push("")
+  parts.push("请根据以上仿真记录,生成结构化推演报告(只输出 JSON)。")
+
+  return parts.join("\n")
+}
+
+// ── 内部辅助:解析报告字段(健壮:字段缺失或类型不符时回退为安全默认值) ──
+
+function clampScore(raw: unknown): number {
+  const n = Number(raw)
+  if (!Number.isFinite(n)) return 0
+  const rounded = Math.round(n)
+  if (rounded < 0) return 0
+  if (rounded > 100) return 100
+  return rounded
+}
+
+function parseBoolean(raw: unknown): boolean {
+  if (typeof raw === "boolean") return raw
+  if (typeof raw === "number") return raw !== 0
+  const v = String(raw ?? "")
+    .trim()
+    .toLowerCase()
+  return v === "true" || v === "1" || v === "yes" || v === "是" || v === "推荐"
+}
+
+function parseProbability(raw: unknown): "high" | "medium" | "low" {
+  const v = String(raw ?? "")
+    .trim()
+    .toLowerCase()
+  if (v === "high" || v === "高") return "high"
+  if (v === "low" || v === "低") return "low"
+  return "medium"
+}
+
+function parseCharacterAnalyses(raw: unknown): CharacterAnalysis[] {
+  if (!Array.isArray(raw)) return []
+  return raw.map((item) => {
+    const obj = (item ?? {}) as Record<string, unknown>
+    const behaviorsRaw = Array.isArray(obj.behaviors) ? obj.behaviors : []
+    return {
+      characterId: String(obj.characterId ?? ""),
+      name: String(obj.name ?? ""),
+      behaviors: behaviorsRaw.map((b) => {
+        const bo = (b ?? {}) as Record<string, unknown>
+        return {
+          node: String(bo.node ?? ""),
+          action: String(bo.action ?? ""),
+          motivation: String(bo.motivation ?? ""),
+        }
+      }),
+      stateChanges: Array.isArray(obj.stateChanges)
+        ? obj.stateChanges.map((s) => String(s))
+        : [],
+      consistencyScore: clampScore(obj.consistencyScore),
+    }
+  })
+}
+
+function parseBranches(raw: unknown): StoryBranch[] {
+  if (!Array.isArray(raw)) return []
+  return raw.map((item) => {
+    const obj = (item ?? {}) as Record<string, unknown>
+    return {
+      title: String(obj.title ?? ""),
+      summary: String(obj.summary ?? ""),
+      keyEvents: Array.isArray(obj.keyEvents)
+        ? obj.keyEvents.map((e) => String(e))
+        : [],
+      probability: parseProbability(obj.probability),
+      pros: String(obj.pros ?? ""),
+      cons: String(obj.cons ?? ""),
+      recommendation: parseBoolean(obj.recommendation),
+    }
+  })
+}
+
+// ── 主入口:生成推演报告 ──
+
+export async function generateSimulationReport(
+  options: ReportGenerationOptions,
+): Promise<SimulationReport> {
+  const { events, framework, mode, llmConfig, onProgress, signal } = options
+  const createdAt = new Date().toISOString()
+
+  onProgress?.("正在构建推演提示词")
+  const messages: ChatMessage[] = [
+    { role: "system", content: buildSystemPrompt(framework, mode) },
+    { role: "user", content: buildUserPrompt(framework, events) },
+  ]
+
+  onProgress?.("正在调用模型生成推演报告")
+  const rawResponse = await collectStream(llmConfig, messages, signal)
+
+  onProgress?.("正在解析推演报告")
+  const jsonText = extractJson(rawResponse)
+
+  // 降级报告:无法解析出 JSON 时,用空数组 + 原始文本作为 recommendation
+  if (!jsonText) {
+    return {
+      frameworkId: framework.id,
+      mode,
+      characterAnalyses: [],
+      branches: [],
+      recommendation: rawResponse.trim() || "模型未返回可解析的推演报告",
+      createdAt,
+    }
+  }
+
+  try {
+    const data = JSON.parse(jsonText) as Record<string, unknown>
+    const characterAnalyses = parseCharacterAnalyses(data.characterAnalyses)
+    const branches = parseBranches(data.branches)
+    const recommendation =
+      String(data.recommendation ?? "").trim() || "模型未提供综合推荐建议"
+
+    return {
+      frameworkId: framework.id,
+      mode,
+      characterAnalyses,
+      branches,
+      recommendation,
+      createdAt,
+    }
+  } catch {
+    // 解析异常时同样降级:保留原始文本,避免中断调用方流程
+    return {
+      frameworkId: framework.id,
+      mode,
+      characterAnalyses: [],
+      branches: [],
+      recommendation: rawResponse.trim() || "模型返回的推演报告无法解析",
+      createdAt,
+    }
+  }
+}