Browse Source

feat(book-analysis): 用 Writing DNA 替换拆书文风模块

拆书文风改为分层蒸馏,并支持任务级深度与模型选择。
分片按截断后字数规划,避免超长章被过度切片。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 month ago
parent
commit
15c00e30fc
37 changed files with 4360 additions and 749 deletions
  1. 17 42
      src/components/novel/book-analysis-result-viewer.tsx
  2. 178 0
      src/components/novel/book-analysis-run-dialog.spec.tsx
  3. 106 5
      src/components/novel/book-analysis-run-dialog.tsx
  4. 102 21
      src/components/novel/book-analysis-style-card.tsx
  5. 36 6
      src/components/novel/book-analysis-view.spec.ts
  6. 17 7
      src/components/novel/book-analysis-view.tsx
  7. 0 67
      src/components/novel/hooks/use-library-operations.ts
  8. 39 6
      src/lib/novel/book-analysis/analysis-chunk-planner.spec.ts
  9. 22 3
      src/lib/novel/book-analysis/analysis-chunk-planner.ts
  10. 16 0
      src/lib/novel/book-analysis/analysis-model-resolver.ts
  11. 17 0
      src/lib/novel/book-analysis/analysis-pipeline-types.ts
  12. 41 0
      src/lib/novel/book-analysis/analysis-scheduler.spec.ts
  13. 8 4
      src/lib/novel/book-analysis/analysis-scheduler.ts
  14. 247 0
      src/lib/novel/book-analysis/chapter-meta.spec.ts
  15. 259 0
      src/lib/novel/book-analysis/chapter-meta.ts
  16. 3 1
      src/lib/novel/book-analysis/library-state.ts
  17. 2 1
      src/lib/novel/book-analysis/result-loader.ts
  18. 451 54
      src/lib/novel/book-analysis/style-analysis-adapter.spec.ts
  19. 353 97
      src/lib/novel/book-analysis/style-analysis-adapter.ts
  20. 0 153
      src/lib/novel/book-analysis/style-extraction-engine.ts
  21. 210 0
      src/lib/novel/book-analysis/style-metrics.spec.ts
  22. 438 0
      src/lib/novel/book-analysis/style-metrics.ts
  23. 175 0
      src/lib/novel/book-analysis/style-profile-schema.spec.ts
  24. 148 0
      src/lib/novel/book-analysis/style-profile-schema.ts
  25. 0 63
      src/lib/novel/book-analysis/style-prompts.spec.ts
  26. 0 180
      src/lib/novel/book-analysis/style-prompts.ts
  27. 40 15
      src/lib/novel/book-analysis/types.ts
  28. 16 1
      src/lib/novel/book-analysis/verification-engine.ts
  29. 272 0
      src/lib/novel/book-analysis/writing-dna-prompts.spec.ts
  30. 408 0
      src/lib/novel/book-analysis/writing-dna-prompts.ts
  31. 146 0
      src/lib/novel/book-analysis/writing-dna-render.spec.ts
  32. 189 0
      src/lib/novel/book-analysis/writing-dna-render.ts
  33. 2 2
      src/lib/novel/context-data-sources.ts
  34. 126 0
      src/lib/novel/writing-style-store.spec.ts
  35. 106 13
      src/lib/novel/writing-style-store.ts
  36. 149 0
      src/stores/book-analysis-pipeline-store.model-depth.spec.ts
  37. 21 8
      src/stores/book-analysis-pipeline-store.ts

+ 17 - 42
src/components/novel/book-analysis-result-viewer.tsx

@@ -10,14 +10,10 @@ import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { bindCharacterAura, listBindableNovelCharacters } from "@/lib/novel/character-aura"
 import { importBookAnalysisSkillsAsAuras, type ImportedBookAnalysisAura } from "@/lib/novel/book-analysis/aura-adapter"
-import { analyzeWritingStyle } from "@/lib/novel/book-analysis/style-extraction-engine"
-import { STYLE_DIMENSIONS } from "@/lib/novel/book-analysis/style-prompts"
+import { WRITING_DNA_LAYERS, needsReextraction } from "@/lib/novel/book-analysis/style-profile-schema"
 import { upsertWritingStylePreset, setEnabledWritingStyle, getEnabledWritingStyle } from "@/lib/novel/writing-style-store"
-import { joinPath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
 import { refreshProjectState } from "@/lib/project-refresh"
-import { resolveDefaultModel } from "@/lib/novel/model-resolver"
-import { hasUsableLlm } from "@/lib/has-usable-llm"
 import type { BookAnalysisResult, BookAnalysisMetadata, ExtractedCharacter, PersonalityProfile } from "@/lib/novel/book-analysis/types"
 
 interface BookAnalysisResultViewerProps {
@@ -38,8 +34,7 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
   const [selectedAuraId, setSelectedAuraId] = useState("")
   // feature/fix-viewer-ui:多选小说人物
   const [selectedNovelCharacterIds, setSelectedNovelCharacterIds] = useState<Set<string>>(new Set())
-  // feature/book-style-extraction:作品文风提取 / 启用
-  const [styleExtracting, setStyleExtracting] = useState(false)
+  // feature/book-style-extraction:作品文风启用态
   const [styleEnabledSourceBook, setStyleEnabledSourceBook] = useState<string | null>(null)
 
   const currentProject = useWikiStore((s) => s.project)
@@ -115,33 +110,9 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
     ? [...characters].sort((a, b) => b.importance - a.importance || a.name.localeCompare(b.name, "zh-CN"))
     : characters
 
-  // feature/book-style-extraction:提取作品级写作文风
-  const handleExtractStyle = async () => {
-    if (!currentProject?.path || styleExtracting) return
-    const bookId = task?.bookId
-    if (!bookId) {
-      toast.error("未找到作品标识")
-      return
-    }
-    const storeState = useWikiStore.getState()
-    const llmConfig = resolveDefaultModel(storeState.llmConfig)
-    if (!hasUsableLlm(llmConfig, storeState.providerConfigs)) {
-      toast.error("未配置 LLM,请先在设置中配置")
-      return
-    }
-    const bookPath = joinPath(currentProject.path, "book-analysis", bookId)
-    setStyleExtracting(true)
-    try {
-      const profile = await analyzeWritingStyle(bookPath, llmConfig)
-      if (task) useBookAnalysisStore.getState().updateTaskStyleProfile(task.id, profile)
-      const cur = useBookAnalysisStore.getState().currentResult
-      if (cur) useBookAnalysisStore.getState().setCurrentResult({ ...cur, styleProfile: profile })
-      toast.success("已提取作品文风")
-    } catch (err) {
-      toast.error(`提取文风失败:${err instanceof Error ? err.message : String(err)}`)
-    } finally {
-      setStyleExtracting(false)
-    }
+  // Writing DNA 分层蒸馏需要用户先选章节范围,只能从拆书库的 pipeline 入口发起
+  const handleExtractStyle = () => {
+    toast.info("请在拆书库中提取文风:分层蒸馏需要先选择章节范围。")
   }
 
   // feature/book-style-extraction:启用 / 取消启用该作品文风
@@ -312,7 +283,9 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
                 <Feather className="h-4 w-4 text-muted-foreground shrink-0" />
                 <span className="text-sm font-medium shrink-0">作品文风</span>
                 <span className="text-xs text-muted-foreground truncate">
-                  {styleProfile ? (styleProfile.narrativeDensity || "已提取") : "尚未提取叙事文风(与角色灵魂相互独立)"}
+                  {styleProfile
+                    ? (needsReextraction(styleProfile) ? "旧版画像,建议重新提取" : "已蒸馏 Writing DNA")
+                    : "尚未提取叙事文风(与角色灵魂相互独立)"}
                 </span>
               </div>
               <div className="flex items-center gap-2 shrink-0">
@@ -325,8 +298,8 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
                     {styleEnabled ? "已启用 ✓" : "启用此文风"}
                   </Button>
                 )}
-                <Button variant="outline" size="sm" onClick={handleExtractStyle} disabled={styleExtracting}>
-                  {styleExtracting ? "提取中..." : styleProfile ? "重新提取文风" : "提取文风"}
+                <Button variant="outline" size="sm" onClick={handleExtractStyle}>
+                  {styleProfile ? "重新提取文风" : "提取文风"}
                 </Button>
               </div>
             </div>
@@ -334,11 +307,13 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
               也可以在拆书库主界面统一管理文风、角色 Skill 和绑定关系。
             </div>
             {styleProfile && (
-              <div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
-                {STYLE_DIMENSIONS.map((d) => (
-                  <div key={d.key} className="min-w-0">
-                    <span className="text-foreground">{d.label}:</span>
-                    <span className="break-all">{(styleProfile[d.key] as string) || "\u2014"}</span>
+              <div className="mt-2 space-y-1 text-xs text-muted-foreground">
+                {WRITING_DNA_LAYERS.map((layer) => (
+                  <div key={layer.key} className="min-w-0">
+                    <span className="text-foreground">{layer.level} {layer.label}:</span>
+                    <span className="break-all">
+                      {styleProfile.layers?.[layer.key]?.trim() ? "已蒸馏" : "\u2014"}
+                    </span>
                   </div>
                 ))}
               </div>

+ 178 - 0
src/components/novel/book-analysis-run-dialog.spec.tsx

@@ -0,0 +1,178 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot } from "react-dom/client"
+import { describe, expect, it, vi } from "vitest"
+import { BookAnalysisRunDialog } from "./book-analysis-run-dialog"
+import {
+  buildAnalysisChunkPlan,
+  computeAnalysisChunkCharLimit,
+} from "@/lib/novel/book-analysis/analysis-chunk-planner"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
+
+// 模型下拉本身依赖 wiki store 与 provider 配置,这里只关心它选中的值有没有透传出去
+vi.mock("@/components/chat/chat-model-selector", () => ({
+  ChatModelSelector: ({ value, onChange }: { value: string; onChange: (model: string) => void }) => (
+    <input aria-label="分析模型" value={value} onChange={(event) => onChange(event.target.value)} />
+  ),
+}))
+
+vi.mock("@/lib/novel/book-analysis/analysis-model-resolver", () => ({
+  resolveTaskLlmConfig: () => ({ maxContextSize: 200_000 }),
+}))
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const chapters = Array.from({ length: 3 }, (_, index) => ({
+  chapterId: `ch-000${index + 1}`,
+  title: `第 ${index + 1} 章`,
+  order: index + 1,
+  wordCount: 1000,
+  selected: false,
+  analyzed: false,
+}))
+
+function renderDialog(props: Partial<Parameters<typeof BookAnalysisRunDialog>[0]> = {}) {
+  const container = document.createElement("div")
+  document.body.appendChild(container)
+  const root = createRoot(container)
+  act(() => {
+    root.render(
+      <BookAnalysisRunDialog
+        open
+        chapters={chapters}
+        initialSkills={["style"]}
+        initialRange={{ startOrder: 1, endOrder: 2 }}
+        onOpenChange={vi.fn()}
+        onSubmit={vi.fn()}
+        {...props}
+      />,
+    )
+  })
+  return {
+    cleanup: () => {
+      act(() => root.unmount())
+      document.body.removeChild(container)
+    },
+  }
+}
+
+function queryAll(selector: string): HTMLElement[] {
+  return Array.from(document.body.querySelectorAll<HTMLElement>(selector))
+}
+
+function findByText(selector: string, text: string): HTMLElement | undefined {
+  return queryAll(selector).find((element) => element.textContent?.includes(text))
+}
+
+function setInputValue(input: HTMLInputElement, value: string) {
+  const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set
+  setter?.call(input, value)
+  input.dispatchEvent(new Event("input", { bubbles: true }))
+}
+
+describe("BookAnalysisRunDialog 深度与模型", () => {
+  it("勾选文风时显示深度单选,默认完整", async () => {
+    const { cleanup } = renderDialog()
+    await act(async () => { await Promise.resolve() })
+
+    const depthRadios = queryAll('input[name="style-depth"]') as HTMLInputElement[]
+    expect(depthRadios.map((radio) => radio.value)).toEqual(["full", "fast"])
+    expect(depthRadios.find((radio) => radio.checked)?.value).toBe("full")
+    cleanup()
+  })
+
+  it("未勾选文风时不显示深度单选", async () => {
+    const { cleanup } = renderDialog({ initialSkills: ["story"], lockedSkills: ["story"] })
+    await act(async () => { await Promise.resolve() })
+
+    expect(queryAll('input[name="style-depth"]')).toHaveLength(0)
+    cleanup()
+  })
+
+  it("提交时带上所选深度与模型", async () => {
+    const onSubmit = vi.fn()
+    const { cleanup } = renderDialog({ onSubmit })
+    await act(async () => { await Promise.resolve() })
+
+    const fastRadio = queryAll('input[name="style-depth"]').find(
+      (radio) => (radio as HTMLInputElement).value === "fast",
+    ) as HTMLInputElement
+    await act(async () => { fastRadio.click() })
+
+    const modelInput = document.body.querySelector<HTMLInputElement>('input[aria-label="分析模型"]')!
+    await act(async () => { setInputValue(modelInput, " openai/gpt-4o-mini ") })
+
+    const submitButton = findByText("button", "开始分析")!
+    await act(async () => { submitButton.click() })
+
+    expect(onSubmit).toHaveBeenCalledWith({
+      range: { startOrder: 1, endOrder: 2 },
+      selectedSkills: ["style"],
+      modelKey: "openai/gpt-4o-mini",
+      styleDepth: "fast",
+    })
+    cleanup()
+  })
+
+  it("打开时回填上次用过的模型与深度", async () => {
+    const { cleanup } = renderDialog({
+      initialModelKey: "openai/gpt-4o-mini",
+      initialStyleDepth: "fast",
+    })
+    await act(async () => { await Promise.resolve() })
+
+    const modelInput = document.body.querySelector<HTMLInputElement>('input[aria-label="分析模型"]')!
+    expect(modelInput.value).toBe("openai/gpt-4o-mini")
+    const checked = (queryAll('input[name="style-depth"]') as HTMLInputElement[]).find((radio) => radio.checked)
+    expect(checked?.value).toBe("fast")
+    cleanup()
+  })
+})
+
+describe("BookAnalysisRunDialog 分片预估", () => {
+  const babylonChapters = [9683, 20060, 26139, 21322, 50165, 60700, 25222, 20085].map((wordCount, index) => ({
+    chapterId: `ch-${String(index + 1).padStart(4, "0")}`,
+    title: `第 ${index + 1} 章`,
+    order: index + 1,
+    wordCount,
+    selected: false,
+    analyzed: false,
+  }))
+
+  it("预估片数与 buildAnalysisChunkPlan 一致", async () => {
+    const { cleanup } = renderDialog({
+      chapters: babylonChapters,
+      initialRange: { startOrder: 1, endOrder: 8 },
+    })
+    await act(async () => { await Promise.resolve() })
+
+    const expected = buildAnalysisChunkPlan(
+      babylonChapters.map((chapter) => ({ id: chapter.chapterId, order: chapter.order, wordCount: chapter.wordCount })),
+      { startOrder: 1, endOrder: 8 },
+      { maxChunkChars: computeAnalysisChunkCharLimit(200_000) },
+    )
+    expect(findByText("p", `预计 ${expected.length} 个章节区块`)).toBeTruthy()
+    cleanup()
+  })
+
+  it("范围内有超长章节时出现截断提示", async () => {
+    const mixed = Array.from({ length: 8 }, (_, index) => ({
+      chapterId: `ch-${String(index + 1).padStart(4, "0")}`,
+      title: `第 ${index + 1} 章`,
+      order: index + 1,
+      wordCount: index === 4 ? 50165 : index === 5 ? 60700 : 3000,
+      selected: false,
+      analyzed: false,
+    }))
+    const { cleanup } = renderDialog({
+      chapters: mixed,
+      initialRange: { startOrder: 1, endOrder: 8 },
+    })
+    await act(async () => { await Promise.resolve() })
+
+    const limit = CHAPTER_BODY_EXCERPT_MAX_CHARS.toLocaleString()
+    expect(findByText("p", `第 5、6 章超过 ${limit} 字,仅前 ${limit} 字会被分析`)).toBeTruthy()
+    cleanup()
+  })
+})

+ 106 - 5
src/components/novel/book-analysis-run-dialog.tsx

@@ -4,10 +4,20 @@ import { Button } from "@/components/ui/button"
 import type { ChapterSelectionState } from "@/lib/novel/book-analysis/types"
 import {
   ANALYSIS_SKILL_ORDER,
+  DEFAULT_STYLE_ANALYSIS_DEPTH,
   type AnalysisChapterRange,
   type AnalysisSkill,
+  type StyleAnalysisDepth,
 } from "@/lib/novel/book-analysis/analysis-pipeline-types"
-import { MAX_ANALYSIS_CHAPTERS } from "@/lib/novel/book-analysis/analysis-chunk-planner"
+import {
+  MAX_ANALYSIS_CHAPTERS,
+  MAX_ANALYSIS_CHUNK_CHARS,
+  buildAnalysisChunkPlan,
+  computeAnalysisChunkCharLimit,
+} from "@/lib/novel/book-analysis/analysis-chunk-planner"
+import { resolveTaskLlmConfig } from "@/lib/novel/book-analysis/analysis-model-resolver"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
+import { ChatModelSelector } from "@/components/chat/chat-model-selector"
 
 interface BookAnalysisRunDialogProps {
   open: boolean
@@ -15,8 +25,15 @@ interface BookAnalysisRunDialogProps {
   initialSkills?: AnalysisSkill[]
   lockedSkills?: AnalysisSkill[]
   initialRange?: AnalysisChapterRange | null
+  initialModelKey?: string
+  initialStyleDepth?: StyleAnalysisDepth
   onOpenChange: (open: boolean) => void
-  onSubmit: (value: { range: AnalysisChapterRange; selectedSkills: AnalysisSkill[] }) => Promise<void> | void
+  onSubmit: (value: {
+    range: AnalysisChapterRange
+    selectedSkills: AnalysisSkill[]
+    modelKey: string
+    styleDepth: StyleAnalysisDepth
+  }) => Promise<void> | void
 }
 
 const SKILL_LABELS: Record<AnalysisSkill, string> = {
@@ -25,18 +42,35 @@ const SKILL_LABELS: Record<AnalysisSkill, string> = {
   style: "文风 Skill",
 }
 
+const DEPTH_OPTIONS: Array<{ value: StyleAnalysisDepth; label: string; hint: string }> = [
+  {
+    value: "full",
+    label: "完整",
+    hint: "跑齐 L1-L6 六层,每个章节区块 3 次模型调用,结果最细但最慢最贵",
+  },
+  {
+    value: "fast",
+    label: "快速",
+    hint: "只跑语言与排版层,每个章节区块 1 次调用,代表片段由脚本截取;适合大部头先出结果",
+  },
+]
+
 export function BookAnalysisRunDialog({
   open,
   chapters,
   initialSkills = [],
   lockedSkills,
   initialRange,
+  initialModelKey = "",
+  initialStyleDepth = DEFAULT_STYLE_ANALYSIS_DEPTH,
   onOpenChange,
   onSubmit,
 }: BookAnalysisRunDialogProps) {
   const [start, setStart] = useState("")
   const [end, setEnd] = useState("")
   const [skills, setSkills] = useState<AnalysisSkill[]>([])
+  const [modelKey, setModelKey] = useState("")
+  const [styleDepth, setStyleDepth] = useState<StyleAnalysisDepth>(DEFAULT_STYLE_ANALYSIS_DEPTH)
   const [submitting, setSubmitting] = useState(false)
 
   useEffect(() => {
@@ -44,7 +78,9 @@ export function BookAnalysisRunDialog({
     setStart(initialRange ? String(initialRange.startOrder) : "")
     setEnd(initialRange ? String(initialRange.endOrder) : "")
     setSkills(lockedSkills?.length ? [...lockedSkills] : [...initialSkills])
-  }, [initialRange, initialSkills, lockedSkills, open])
+    setModelKey(initialModelKey)
+    setStyleDepth(initialStyleDepth)
+  }, [initialModelKey, initialRange, initialSkills, initialStyleDepth, lockedSkills, open])
 
   const range = useMemo<AnalysisChapterRange | null>(() => {
     const startOrder = Number(start)
@@ -72,12 +108,40 @@ export function BookAnalysisRunDialog({
             ? `第 ${missingChapter} 章不存在,请根据作品实际章节范围选择`
             : ""
   const canSubmit = Boolean(range && !error && skills.length > 0 && !submitting)
+  const estimatedChunks = useMemo(() => {
+    if (!range || !start || !end || error) return []
+    const llmConfig = resolveTaskLlmConfig({ modelKey })
+    try {
+      return buildAnalysisChunkPlan(
+        chapters.map((chapter) => ({ id: chapter.chapterId, order: chapter.order, wordCount: chapter.wordCount })),
+        range,
+        { maxChunkChars: computeAnalysisChunkCharLimit(llmConfig.maxContextSize) },
+      )
+    } catch {
+      return []
+    }
+  }, [chapters, end, error, modelKey, range, start])
+  const truncatedOrders = useMemo(() => {
+    if (!range || !start || !end || error) return []
+    return chapters
+      .filter((chapter) => (
+        chapter.order >= range.startOrder
+        && chapter.order <= range.endOrder
+        && chapter.wordCount > CHAPTER_BODY_EXCERPT_MAX_CHARS
+      ))
+      .map((chapter) => chapter.order)
+  }, [chapters, end, error, range, start])
 
   const submit = async () => {
     if (!range || !canSubmit) return
     setSubmitting(true)
     try {
-      await onSubmit({ range, selectedSkills: ANALYSIS_SKILL_ORDER.filter((skill) => skills.includes(skill)) })
+      await onSubmit({
+        range,
+        selectedSkills: ANALYSIS_SKILL_ORDER.filter((skill) => skills.includes(skill)),
+        modelKey: modelKey.trim(),
+        styleDepth,
+      })
       onOpenChange(false)
     } finally {
       setSubmitting(false)
@@ -133,8 +197,45 @@ export function BookAnalysisRunDialog({
               )}
             </div>
           </fieldset>
+          {skills.includes("style") && (
+            <fieldset>
+              <legend className="text-sm font-medium">文风蒸馏深度</legend>
+              <div className="mt-2 space-y-2">
+                {DEPTH_OPTIONS.map((option) => (
+                  <label key={option.value} className="flex gap-2 text-sm">
+                    <input
+                      type="radio"
+                      name="style-depth"
+                      className="mt-1 shrink-0"
+                      value={option.value}
+                      checked={styleDepth === option.value}
+                      onChange={() => setStyleDepth(option.value)}
+                    />
+                    <span>
+                      <span className="font-medium">{option.label}</span>
+                      <span className="ml-2 text-xs text-muted-foreground">{option.hint}</span>
+                    </span>
+                  </label>
+                ))}
+              </div>
+            </fieldset>
+          )}
+          <div className="space-y-1.5">
+            <span className="text-sm font-medium">分析模型</span>
+            <ChatModelSelector value={modelKey} onChange={setModelKey} disabled={submitting} />
+            <p className="text-xs text-muted-foreground">
+              不选则使用项目默认模型。每个章节区块最多喂 {MAX_ANALYSIS_CHUNK_CHARS.toLocaleString()} 字正文。
+            </p>
+          </div>
           {range && !error && (
-            <p className="text-sm text-muted-foreground">预计 {Math.ceil(count / 10)} 个章节区块</p>
+            <div className="space-y-1 text-sm text-muted-foreground">
+              <p>预计 {estimatedChunks.length} 个章节区块</p>
+              {truncatedOrders.length > 0 && (
+                <p role="status">
+                  第 {truncatedOrders.join("、")} 章超过 {CHAPTER_BODY_EXCERPT_MAX_CHARS.toLocaleString()} 字,仅前 {CHAPTER_BODY_EXCERPT_MAX_CHARS.toLocaleString()} 字会被分析
+                </p>
+              )}
+            </div>
           )}
         </div>
         <DialogFooter className="shrink-0">

+ 102 - 21
src/components/novel/book-analysis-style-card.tsx

@@ -2,7 +2,12 @@ import { useState } from "react"
 import { ChevronDown, ChevronUp, Feather, Loader2, Trash2 } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import type { BookAnalysisLibraryBook } from "@/lib/novel/book-analysis/library-state"
-import { STYLE_DIMENSIONS } from "@/lib/novel/book-analysis/style-prompts"
+import {
+  INTEGRATED_DNA_CHAR_LIMIT,
+  WRITING_DNA_LAYERS,
+  needsReextraction,
+} from "@/lib/novel/book-analysis/style-profile-schema"
+import type { StyleMetrics } from "@/lib/novel/book-analysis/style-metrics"
 
 interface BookAnalysisStyleCardProps {
   book: BookAnalysisLibraryBook
@@ -12,10 +17,54 @@ interface BookAnalysisStyleCardProps {
   onDeleteStyle: () => void
 }
 
+const percent = (value: number): string => `${(value * 100).toFixed(1)}%`
+
+/** 统计数字面板:这些是脚本算出来的确定值,不是模型的主观判断,值得单独摆出来。 */
+function MetricsPanel({ metrics }: { metrics: StyleMetrics }) {
+  const { counts, derived } = metrics
+  const items: Array<[string, string]> = [
+    ["样本", `${counts.chapters} 章 / ${counts.chars} 字`],
+    ["平均句长", `${derived.avgSentenceChars} 字`],
+    ["短句占比", percent(derived.shortSentenceRatio)],
+    ["长句占比", percent(derived.longSentenceRatio)],
+    ["平均段落", `${derived.avgParagraphChars} 字 / ${derived.avgParagraphSentences} 句`],
+    ["一句一段", percent(derived.oneSentenceParagraphRatio)],
+    ["含对白段落", percent(derived.dialogueParagraphRatio)],
+    ["破折号 / 千字", String(derived.punctuationPerThousand.dash)],
+    ["省略号 / 千字", String(derived.punctuationPerThousand.ellipsis)],
+  ]
+  return (
+    <div className="rounded-md bg-muted/40 p-3 text-xs">
+      <div className="font-medium">脚本统计(L1 / L6 确定性指标)</div>
+      <div className="mt-2 grid gap-x-4 gap-y-1 sm:grid-cols-3">
+        {items.map(([label, value]) => (
+          <div key={label} className="flex items-baseline justify-between gap-2">
+            <span className="text-muted-foreground">{label}</span>
+            <span className="font-mono text-foreground">{value}</span>
+          </div>
+        ))}
+      </div>
+      {metrics.topWords.length > 0 && (
+        <div className="mt-2 text-muted-foreground leading-5">
+          <span className="text-foreground">高频实词:</span>
+          {metrics.topWords.slice(0, 12).map((item) => `${item.word}(${item.count})`).join("、")}
+        </div>
+      )}
+      {!metrics.segmenterAvailable && (
+        <div className="mt-2 text-amber-600 dark:text-amber-500">
+          当前环境缺少中文分词能力,词频表为相邻汉字二元窗口的近似结果。
+        </div>
+      )}
+    </div>
+  )
+}
+
 export function BookAnalysisStyleCard({ book, extracting, onExtractStyle, onToggleStyle, onDeleteStyle }: BookAnalysisStyleCardProps) {
   const profile = book.styleProfile
   const enabled = book.styleStatus === "enabled"
   const [expanded, setExpanded] = useState(false)
+  const stale = profile ? needsReextraction(profile) : false
+  const integratedLength = profile?.integratedDna?.trim().length ?? 0
 
   return (
     <section className="rounded-lg border bg-background p-4">
@@ -23,13 +72,17 @@ export function BookAnalysisStyleCard({ book, extracting, onExtractStyle, onTogg
         <div className="min-w-0">
           <div className="flex items-center gap-2">
             <Feather className="h-4 w-4 text-primary" />
-            <h3 className="text-sm font-semibold">作品文风</h3>
+            <h3 className="text-sm font-semibold">作品文风 · Writing DNA</h3>
             {enabled && (
               <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">已启用</span>
             )}
           </div>
           <p className="mt-2 text-sm leading-6 text-muted-foreground">
-            {profile?.narrativeDensity || "尚未提取叙事文风。作品文风只约束叙事写法,不等同于角色说话方式。"}
+            {!profile
+              ? "尚未蒸馏 Writing DNA。作品文风只约束叙事写法,不等同于角色说话方式。"
+              : stale
+                ? "这份画像来自旧版单轮提取,没有分层产物与统计指标,建议重新提取。"
+                : `已蒸馏 ${profile.sampledChapterIds.length} 章样本,整合文档 ${integratedLength} 字。`}
           </p>
         </div>
         <div className="flex shrink-0 gap-2">
@@ -52,41 +105,69 @@ export function BookAnalysisStyleCard({ book, extracting, onExtractStyle, onTogg
       </div>
       {profile && (
         <>
-          {/* 始终显示的维度摘要 */}
+          {/* 六层蒸馏状态一览 */}
           <div className="mt-4 grid gap-2 sm:grid-cols-2">
-            {STYLE_DIMENSIONS.slice(0, 4).map((d) => (
-              <div key={d.key} className="rounded-md bg-muted/40 p-3 text-xs">
-                <div className="font-medium">{d.label}</div>
-                <div className="mt-1 text-muted-foreground line-clamp-2">{(profile[d.key] as string) || "\u2014"}</div>
-              </div>
-            ))}
+            {WRITING_DNA_LAYERS.map((layer) => {
+              const text = profile.layers?.[layer.key]?.trim() ?? ""
+              return (
+                <div key={layer.key} className="rounded-md bg-muted/40 p-3 text-xs">
+                  <div className="flex items-center justify-between gap-2">
+                    <span className="font-medium">{layer.level} {layer.label}</span>
+                    <span className={text ? "text-primary" : "text-muted-foreground"}>
+                      {text ? `${text.length} 字` : "未提取"}
+                    </span>
+                  </div>
+                  <div className="mt-1 text-muted-foreground line-clamp-2">{text || layer.summary}</div>
+                </div>
+              )
+            })}
           </div>
-          {/* 展开/收起 */}
+          {profile.metrics && profile.metrics.counts.chapters > 0 && (
+            <div className="mt-2">
+              <MetricsPanel metrics={profile.metrics} />
+            </div>
+          )}
           <button
             type="button"
             className="mt-2 flex items-center gap-1 text-xs text-primary hover:underline"
             onClick={() => setExpanded((v) => !v)}
           >
             {expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
-            {expanded ? "收起详情" : "查看全部维度、风格宪法与代表样本"}
+            {expanded ? "收起详情" : "查看分层产物、整合文档与代表样本"}
           </button>
           {expanded && (
             <div className="mt-3 space-y-4">
-              {/* 剩余维度 */}
-              {STYLE_DIMENSIONS.slice(4).map((d) => (
-                <div key={d.key} className="rounded-md bg-muted/40 p-3 text-xs">
-                  <div className="font-medium">{d.label}</div>
-                  <div className="mt-1 text-muted-foreground leading-5">{(profile[d.key] as string) || "\u2014"}</div>
+              {WRITING_DNA_LAYERS.map((layer) => {
+                const text = profile.layers?.[layer.key]?.trim()
+                if (!text) return null
+                return (
+                  <div key={layer.key} className="rounded-md bg-muted/40 p-3 text-xs">
+                    <div className="font-medium">{layer.level} {layer.label}</div>
+                    <div className="mt-1 text-muted-foreground leading-5 whitespace-pre-line">{text}</div>
+                  </div>
+                )
+              })}
+              {integratedLength > 0 && (
+                <div className="rounded-md bg-muted/40 p-3 text-xs">
+                  <div className="flex items-center justify-between gap-2">
+                    <span className="font-medium">整合文档 Writing-DNA.md(注入生成)</span>
+                    {integratedLength > INTEGRATED_DNA_CHAR_LIMIT && (
+                      <span className="text-amber-600 dark:text-amber-500">
+                        超过 {INTEGRATED_DNA_CHAR_LIMIT} 字上限,建议重新提取
+                      </span>
+                    )}
+                  </div>
+                  <div className="mt-1 text-muted-foreground leading-5 whitespace-pre-line">
+                    {profile.integratedDna}
+                  </div>
                 </div>
-              ))}
-              {/* 风格宪法 */}
+              )}
               {profile.constitution && (
                 <div className="rounded-md bg-muted/40 p-3 text-xs">
-                  <div className="font-medium">风格宪法(注入生成)</div>
+                  <div className="font-medium">风格硬约束</div>
                   <div className="mt-1 text-muted-foreground leading-5 whitespace-pre-line">{profile.constitution}</div>
                 </div>
               )}
-              {/* 代表样本 */}
               {profile.samples && profile.samples.length > 0 && (
                 <div className="rounded-md bg-muted/40 p-3 text-xs">
                   <div className="font-medium">代表原文样本</div>

+ 36 - 6
src/components/novel/book-analysis-view.spec.ts

@@ -46,9 +46,10 @@ const mocks = vi.hoisted(() => {
   const setShowResultViewer = vi.fn()
   const clearRecognition = vi.fn()
   const reloadLibraryState = vi.fn()
-  const handleLibraryExtractStyle = vi.fn()
   const handleLibraryReextractCharacters = vi.fn()
   const loadBookStoryFrameworkChapters = vi.fn()
+  const createAwaitingRangeTask = vi.fn()
+  const loadChapterList = vi.fn()
 
   return {
     candidates,
@@ -103,9 +104,28 @@ const mocks = vi.hoisted(() => {
     setShowResultViewer,
     clearRecognition,
     reloadLibraryState,
-    handleLibraryExtractStyle,
     handleLibraryReextractCharacters,
     loadBookStoryFrameworkChapters,
+    createAwaitingRangeTask,
+    loadChapterList,
+    pipelineState: {
+      tasks: [] as unknown[],
+      chunks: [] as unknown[],
+      progresses: {} as Record<string, unknown>,
+      initializeProject: vi.fn(async () => {}),
+      createAwaitingRangeTask,
+      configureTaskRange: vi.fn(async () => {}),
+      setTaskRecognizedCharacters: vi.fn(async () => {}),
+      failTask: vi.fn(async () => {}),
+      confirmCharacterSelection: vi.fn(async () => {}),
+      setRuntimeProgress: vi.fn(),
+      startTask: vi.fn(async () => {}),
+      pauseTask: vi.fn(async () => {}),
+      continueTask: vi.fn(async () => {}),
+      retryFailedChunk: vi.fn(async () => {}),
+      cancelTask: vi.fn(async () => {}),
+      dispose: vi.fn(async () => {}),
+    },
     reloadedLibraryState: libraryState,
     libraryParams: null as any,
     inputDialogProps: null as any,
@@ -130,6 +150,14 @@ vi.mock("@/stores/book-analysis-store", () => {
   return { useBookAnalysisStore }
 })
 
+vi.mock("@/stores/book-analysis-pipeline-store", () => {
+  const useBookAnalysisPipelineStore = Object.assign(
+    (selector: (state: typeof mocks.pipelineState) => unknown) => selector(mocks.pipelineState),
+    { getState: () => mocks.pipelineState },
+  )
+  return { useBookAnalysisPipelineStore }
+})
+
 vi.mock("@/stores/book-analysis-import-store", () => {
   const useBookAnalysisImportStore = Object.assign(
     (selector: (state: typeof mocks.importState) => unknown) => selector(mocks.importState),
@@ -142,10 +170,8 @@ vi.mock("./hooks/use-library-operations", () => ({
   useLibraryOperations: (params: any) => {
     mocks.libraryParams = params
     return {
-      styleExtracting: false,
       addingToSoul: false,
       reloadLibraryState: mocks.reloadLibraryState,
-      handleLibraryExtractStyle: mocks.handleLibraryExtractStyle,
       handleLibraryToggleStyle: vi.fn(),
       handleLibraryAddSkillsToSoul: vi.fn(),
       handleLibraryDeleteBook: vi.fn(),
@@ -329,8 +355,9 @@ beforeEach(() => {
   mocks.setCurrentResult.mockReset()
   mocks.setShowResultViewer.mockReset()
   mocks.clearRecognition.mockReset()
-  mocks.handleLibraryExtractStyle.mockReset()
   mocks.handleLibraryReextractCharacters.mockReset()
+  mocks.createAwaitingRangeTask.mockReset().mockResolvedValue({ id: "pipeline-task-1" })
+  mocks.loadChapterList.mockReset().mockResolvedValue([])
   mocks.loadBookStoryFrameworkChapters.mockReset().mockResolvedValue([
     { id: "chapter-1", title: "第一章", order: 1, content: "正文" },
   ])
@@ -489,7 +516,10 @@ describe("BookAnalysisView 批量导入运行时接线", () => {
     await clickButton("旧框架入口")
 
     expect(mocks.handleLibraryReextractCharacters).toHaveBeenCalledTimes(1)
-    expect(mocks.handleLibraryExtractStyle).toHaveBeenCalledTimes(1)
+    // 旧文风入口不再走单轮提取,统一走 pipeline 的 style skill
+    expect(mocks.createAwaitingRangeTask).toHaveBeenCalledWith(
+      expect.objectContaining({ bookId: "book-1", selectedSkills: ["style"] }),
+    )
     expect(mocks.loadBookStoryFrameworkChapters).toHaveBeenCalledWith(mocks.libraryState.books[0].path)
     expect(host.querySelector('[data-testid="章节选择面板"]')).not.toBeNull()
     expect(host.querySelector('[data-testid="历史结果查看器"]')).not.toBeNull()

+ 17 - 7
src/components/novel/book-analysis-view.tsx

@@ -41,6 +41,7 @@ import {
   analysisProgressKey,
   type AnalysisChapterRange,
   type AnalysisSkill,
+  type StyleAnalysisDepth,
 } from "@/lib/novel/book-analysis/analysis-pipeline-types"
 import { loadChapterList } from "@/lib/novel/book-analysis/analysis-engine"
 import { BookAnalysisRunDialog } from "./book-analysis-run-dialog"
@@ -110,6 +111,8 @@ export function BookAnalysisView() {
     initialSkills: AnalysisSkill[]
     lockedSkills?: AnalysisSkill[]
     initialRange?: AnalysisChapterRange | null
+    initialModelKey?: string
+    initialStyleDepth?: StyleAnalysisDepth
   } | null>(null)
   const [characterSkillDialogOpen, setCharacterSkillDialogOpen] = useState(false)
   const [characterSkillGenerating, setCharacterSkillGenerating] = useState(false)
@@ -200,10 +203,8 @@ export function BookAnalysisView() {
 
   // 作品库操作钩子
   const {
-    styleExtracting,
     addingToSoul,
     reloadLibraryState,
-    handleLibraryExtractStyle,
     handleLibraryToggleStyle,
     handleLibraryAddSkillsToSoul,
     handleLibraryDeleteBook,
@@ -650,8 +651,11 @@ export function BookAnalysisView() {
       initialSkills: [skill],
       lockedSkills: [skill],
       initialRange: existingModule?.range ?? null,
+      // 沿用上一次该书用过的模型与深度,省得每次重选
+      initialModelKey: selectedPipelineTask?.modelKey ?? "",
+      initialStyleDepth: selectedPipelineTask?.styleDepth,
     })
-  }, [createAwaitingRangeTask, selectedLibraryBook])
+  }, [createAwaitingRangeTask, selectedLibraryBook, selectedPipelineTask])
 
   const openExistingPipelineDialog = useCallback(async () => {
     if (!selectedLibraryBook || !selectedPipelineTask) return
@@ -660,6 +664,8 @@ export function BookAnalysisView() {
       chapters: await loadChapterList(selectedLibraryBook.path),
       initialSkills: selectedPipelineTask.selectedSkills,
       initialRange: selectedPipelineTask.range,
+      initialModelKey: selectedPipelineTask.modelKey ?? "",
+      initialStyleDepth: selectedPipelineTask.styleDepth,
     })
   }, [selectedLibraryBook, selectedPipelineTask])
 
@@ -791,7 +797,9 @@ export function BookAnalysisView() {
       state={libraryState}
       selectedBookId={selectedLibraryBook?.id ?? selectedBookId}
       selectedCharacterId={selectedCharacterId}
-      extractingStyle={styleExtracting}
+      extractingStyle={
+        selectedPipelineTask?.status === "running" && selectedPipelineTask.currentSkill === "style"
+      }
       extractingCharacters={chapterSelectionData !== null}
       extractingStoryFramework={storyFrameworkExtracting}
       addingToSoul={addingToSoul}
@@ -824,7 +832,7 @@ export function BookAnalysisView() {
       onSelectBook={handleSelectBook}
       onSelectCharacter={setSelectedCharacterId}
       onImportNovel={() => setInputDialogOpen(true)}
-      onExtractStyle={handleLibraryExtractStyle}
+      onExtractStyle={() => void openPipelineDialog("style")}
       onExtractStoryFramework={handleOpenStoryFrameworkSelection}
       onCreateOutlineFromFramework={(frameworkId) => setOutlineCreatorFrameworkId(frameworkId)}
       onOpenStoryMap={(bookPath) => {
@@ -870,12 +878,14 @@ export function BookAnalysisView() {
         initialSkills={pipelineDialog.initialSkills}
         lockedSkills={pipelineDialog.lockedSkills}
         initialRange={pipelineDialog.initialRange}
+        initialModelKey={pipelineDialog.initialModelKey}
+        initialStyleDepth={pipelineDialog.initialStyleDepth}
         onOpenChange={(open) => !open && setPipelineDialog(null)}
-        onSubmit={async ({ range, selectedSkills }) => {
+        onSubmit={async ({ range, selectedSkills, modelKey, styleDepth }) => {
           const taskId = pipelineDialog.taskId
           const chapters = pipelineDialog.chapters
           const bookPath = useBookAnalysisPipelineStore.getState().tasks.find((t) => t.id === taskId)?.bookPath
-          await configureTaskRange(taskId, range, selectedSkills)
+          await configureTaskRange(taskId, range, selectedSkills, { modelKey, styleDepth })
           setPipelineDialog(null)
           if (selectedSkills.includes("characters")) {
             if (!bookPath) {

+ 0 - 67
src/components/novel/hooks/use-library-operations.ts

@@ -2,7 +2,6 @@ import { useState, useCallback } from "react"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useBookAnalysisImportStore } from "@/stores/book-analysis-import-store"
 import { useWikiStore } from "@/stores/wiki-store"
-import { analyzeWritingStyle } from "@/lib/novel/book-analysis/style-extraction-engine"
 import { importBookAnalysisSkillsAsAuras } from "@/lib/novel/book-analysis/aura-adapter"
 import { deleteOrphanAurasForBook } from "@/lib/novel/book-analysis/aura-cleanup"
 import {
@@ -56,7 +55,6 @@ export function useLibraryOperations({
   providerConfigs,
   startTask,
 }: UseLibraryOperationsParams) {
-  const [styleExtracting, setStyleExtracting] = useState(false)
   const [addingToSoul, setAddingToSoul] = useState(false)
   const deletePublishedBook = useBookAnalysisImportStore((state) => state.deletePublishedBook)
 
@@ -75,69 +73,6 @@ export function useLibraryOperations({
     )
   }, [currentProjectPath, setLibraryState, setSelectedBookId])
 
-  const handleLibraryExtractStyle = useCallback(async () => {
-    if (!currentProjectPath || !selectedLibraryBook || styleExtracting) return
-    if (!hasUsableLlm(llmConfig, providerConfigs)) {
-      toast.error("未配置可用模型,请先在设置中配置 LLM。")
-      return
-    }
-    setStyleExtracting(true)
-
-    const abortController = new AbortController()
-    const taskId = useBookAnalysisStore.getState().startTask(currentProjectPath, {
-      sourceType: "file",
-      sourcePath: selectedLibraryBook.path,
-      selectedChapters: [],
-    }, abortController)
-    useBookAnalysisStore.getState().updateTaskBookData(taskId, selectedLibraryBook.id, [])
-    useBookAnalysisStore.getState().updateTaskProgress(taskId, {
-      stage: "extracting_style",
-      stageLabel: "提取文风",
-      percentage: 0,
-    })
-
-    try {
-      const progressMap: Record<string, number> = {
-        "读取章节列表…": 10,
-        "读取": 25,
-        "正在分析作品文风…": 50,
-        "保存文风画像…": 90,
-      }
-      const profile = await analyzeWritingStyle(selectedLibraryBook.path, llmConfig, {
-        signal: abortController.signal,
-        onProgress: (msg) => {
-          const pct = Object.entries(progressMap).find(([k]) => msg.includes(k))?.[1]
-          if (pct !== undefined) {
-            useBookAnalysisStore.getState().updateTaskProgress(taskId, {
-              stage: "extracting_style",
-              stageLabel: "提取文风",
-              percentage: pct,
-              currentItem: msg,
-            })
-          }
-        },
-      })
-      useBookAnalysisStore.getState().updateTaskStyleProfile(taskId, profile)
-      useBookAnalysisStore.getState().updateTaskProgress(taskId, {
-        stage: "extracting_style",
-        stageLabel: "提取文风",
-        percentage: 100,
-        currentItem: "完成",
-      })
-      useBookAnalysisStore.getState().completeTask(taskId)
-      toast.success("已提取作品文风。")
-      await reloadLibraryState()
-      useBookAnalysisStore.getState().triggerSidebarRefresh()
-    } catch (err) {
-      const msg = err instanceof Error ? err.message : String(err)
-      console.error("[文风提取失败]", msg)
-      useBookAnalysisStore.getState().errorTask(taskId, msg)
-      toast.error(`提取文风失败:${msg}`)
-    } finally {
-      setStyleExtracting(false)
-    }
-  }, [currentProjectPath, selectedLibraryBook, styleExtracting, llmConfig, reloadLibraryState])
-
   const handleLibraryToggleStyle = useCallback(async () => {
     if (!currentProjectPath || !selectedLibraryBook?.styleProfile) return
     const enabled = libraryState.enabledStyle?.sourceBook === selectedLibraryBook.metadata.title
@@ -388,10 +323,8 @@ export function useLibraryOperations({
   }, [currentProjectPath, selectedLibraryBook, llmConfig, startTask, setChapterSelectionData])
 
   return {
-    styleExtracting,
     addingToSoul,
     reloadLibraryState,
-    handleLibraryExtractStyle,
     handleLibraryToggleStyle,
     handleLibraryAddSkillsToSoul,
     handleLibraryBindCharacter,

+ 39 - 6
src/lib/novel/book-analysis/analysis-chunk-planner.spec.ts

@@ -1,10 +1,14 @@
 import { describe, expect, it } from "vitest"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 import {
   buildAnalysisChunkPlan,
   computeAnalysisChunkCharLimit,
   validateAnalysisRange,
 } from "./analysis-chunk-planner"
 
+/** 重生巴比伦获得钢铁洪流系统 前 8 章的真实字数:按原文预算会切 7 片,按截断后预算应是 3 片。 */
+const babylonWordCounts = [9683, 20060, 26139, 21322, 50165, 60700, 25222, 20085]
+
 const chapters = Array.from({ length: 120 }, (_, index) => ({
   id: `ch-${String(index + 1).padStart(4, "0")}`,
   order: index + 1,
@@ -28,7 +32,7 @@ describe("analysis chunk planner", () => {
       .toThrow("未找到第 12 章")
   })
 
-  it("默认每 10 章切块并把超长章节单独成块", () => {
+  it("默认每 10 章切块,单片不超过目标章数", () => {
     const plan = buildAnalysisChunkPlan(
       chapters,
       { startOrder: 1, endOrder: 30 },
@@ -36,12 +40,41 @@ describe("analysis chunk planner", () => {
     )
 
     expect(plan.flatMap((chunk) => chunk.chapterIds)).toHaveLength(30)
-    expect(plan.some((chunk) => (
-      chunk.chapterIds.length === 1
-      && chunk.startOrder === 15
-      && chunk.endOrder === 15
-    ))).toBe(true)
     expect(plan.every((chunk) => chunk.chapterIds.length <= 10)).toBe(true)
+    // 第 15 章原文 50000,截断后按 12000 计,不再单独成块
+    expect(plan.some((chunk) => chunk.startOrder <= 15 && chunk.endOrder >= 15 && chunk.chapterIds.length > 1)).toBe(true)
+  })
+
+  it("超长章节按截断上限计入预算,不再逐章成片", () => {
+    const babylon = babylonWordCounts.map((wordCount, index) => ({
+      id: `ch-${String(index + 1).padStart(4, "0")}`,
+      order: index + 1,
+      wordCount,
+    }))
+    const plan = buildAnalysisChunkPlan(
+      babylon,
+      { startOrder: 1, endOrder: 8 },
+      { maxChunkChars: 40000 },
+    )
+
+    expect(plan.map((chunk) => `${chunk.startOrder}-${chunk.endOrder}`)).toEqual(["1-3", "4-6", "7-8"])
+    expect(plan[1].wordCount).toBe(21322 + 50165 + 60700)
+  })
+
+  it("单章原文超过 40000 但截断后仍能与相邻章合片", () => {
+    const plan = buildAnalysisChunkPlan(
+      [
+        { id: "ch-0001", order: 1, wordCount: 50000 },
+        { id: "ch-0002", order: 2, wordCount: 2000 },
+      ],
+      { startOrder: 1, endOrder: 2 },
+      { maxChunkChars: 40000 },
+    )
+
+    expect(plan).toHaveLength(1)
+    expect(plan[0].chapterIds).toEqual(["ch-0001", "ch-0002"])
+    expect(plan[0].wordCount).toBe(52000)
+    expect(CHAPTER_BODY_EXCERPT_MAX_CHARS + 2000).toBeLessThan(40000)
   })
 
   it("按模型上下文计算保守字数上限", () => {

+ 22 - 3
src/lib/novel/book-analysis/analysis-chunk-planner.ts

@@ -1,9 +1,13 @@
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 import type {
   AnalysisChapterRange,
   AnalysisChunkPlan,
 } from "./analysis-pipeline-types"
 
 export const MAX_ANALYSIS_CHAPTERS = 100
+/** 单次调用喂给模型的正文上限。失败重试的代价随这值上升,所以故意封顶。 */
+export const MAX_ANALYSIS_CHUNK_CHARS = 40_000
+export const MIN_ANALYSIS_CHUNK_CHARS = 8_000
 const DEFAULT_CHAPTERS_PER_CHUNK = 10
 
 interface AnalysisChapterSummary {
@@ -39,9 +43,20 @@ export function validateAnalysisRange(
   }
 }
 
+/**
+ * 单次调用喂给模型的正文上限(字符),不是按窗口实时推导。
+ * 公式是 context * 0.45 再夹到 [8000, 40000];
+ * 项目的 MIN_USER_LLM_CONTEXT_SIZE = 204_800,0.45 × 204800 = 92160 恒被夹到 40000,
+ * 所以当前所有已配置模型实际都是 40000。
+ */
 export function computeAnalysisChunkCharLimit(maxContextSize: number): number {
   const requested = Math.floor(maxContextSize * 0.45)
-  return Math.max(8000, Math.min(40000, requested))
+  return Math.max(MIN_ANALYSIS_CHUNK_CHARS, Math.min(MAX_ANALYSIS_CHUNK_CHARS, requested))
+}
+
+/** adapter 发给模型前会把每章截到这个长度,预算必须按截断后的量算,否则超长章会被过度切片。 */
+export function analysisBudgetChars(wordCount: number): number {
+  return Math.min(Math.max(0, wordCount), CHAPTER_BODY_EXCERPT_MAX_CHARS)
 }
 
 export function buildAnalysisChunkPlan(
@@ -62,6 +77,7 @@ export function buildAnalysisChunkPlan(
   const chunks: AnalysisChunkPlan[] = []
   let current: AnalysisChapterSummary[] = []
   let currentWordCount = 0
+  let currentBudgetChars = 0
 
   const flush = () => {
     if (current.length === 0) return
@@ -76,15 +92,18 @@ export function buildAnalysisChunkPlan(
     })
     current = []
     currentWordCount = 0
+    currentBudgetChars = 0
   }
 
   for (const chapter of selected) {
+    const budget = analysisBudgetChars(chapter.wordCount)
     const wouldExceedCount = current.length >= targetChapterCount
-    const wouldExceedChars = current.length > 0 && currentWordCount + chapter.wordCount > maxChunkChars
+    const wouldExceedChars = current.length > 0 && currentBudgetChars + budget > maxChunkChars
     if (wouldExceedCount || wouldExceedChars) flush()
     current.push(chapter)
     currentWordCount += chapter.wordCount
-    if (chapter.wordCount > maxChunkChars) flush()
+    currentBudgetChars += budget
+    if (budget > maxChunkChars) flush()
   }
   flush()
   return chunks

+ 16 - 0
src/lib/novel/book-analysis/analysis-model-resolver.ts

@@ -0,0 +1,16 @@
+import { useWikiStore } from "@/stores/wiki-store"
+import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
+import type { BookAnalysisPipelineTask } from "./analysis-pipeline-types"
+
+/**
+ * 解析某个任务实际要用的模型。
+ * 任务上选了 modelKey 就用它,否则沿用项目默认模型。
+ * 选定的 key 解析不出可用配置时也照样返回,由 hasUsableLlm 在启动前拦下来并报错,
+ * 而不是静默换成另一个模型跑完一整本书。
+ */
+export function resolveTaskLlmConfig(task: Pick<BookAnalysisPipelineTask, "modelKey">) {
+  const { llmConfig, providerConfigs } = useWikiStore.getState()
+  const modelKey = task.modelKey?.trim()
+  if (!modelKey) return resolveDefaultModel(llmConfig)
+  return resolveModelConfig(modelKey, llmConfig, providerConfigs)
+}

+ 17 - 0
src/lib/novel/book-analysis/analysis-pipeline-types.ts

@@ -34,6 +34,15 @@ export interface AnalysisChapterRange {
   endOrder: number
 }
 
+/**
+ * 文风 skill 的蒸馏深度(feature/writing-dna)。
+ * full:跑齐 L1-L6,3 次 LLM/分片。
+ * fast:只跑脚本统计 + L1/L6 一次调用,样本与证据由脚本截取,供大部头快速出结果。
+ */
+export type StyleAnalysisDepth = "fast" | "full"
+
+export const DEFAULT_STYLE_ANALYSIS_DEPTH: StyleAnalysisDepth = "full"
+
 export interface AnalysisChunkPlan {
   id: string
   chapterIds: string[]
@@ -84,6 +93,14 @@ export interface BookAnalysisPipelineTask {
   recognizedCharacters?: RecognizedCharacter[]
   /** 用户确认后的深挖目标;有值时角色 adapter 只分析这些角色 */
   targetCharacters?: RecognizedCharacter[]
+  /**
+   * 本任务使用的模型 key(`providerId/modelId` 或裸模型名)。
+   * 留空表示沿用项目默认模型。存在任务上而不是全局,
+   * 是为了让暂停后再继续、以及并行的多个任务各自用回自己当初选的模型。
+   */
+  modelKey?: string
+  /** 文风蒸馏深度;仅 style skill 读取,留空按 DEFAULT_STYLE_ANALYSIS_DEPTH 处理 */
+  styleDepth?: StyleAnalysisDepth
   error: string | null
   createdAt: number
   startedAt: number | null

+ 41 - 0
src/lib/novel/book-analysis/analysis-scheduler.spec.ts

@@ -435,4 +435,45 @@ describe("analysis scheduler", () => {
     expect(aggregateSignal?.aborted).toBe(true)
     expect(scheduler.getSnapshot().tasks[0].status).toBe("cancelled")
   })
+
+  it("llmConfig 传函数时按任务解析,adapter 拿到该任务选定的模型", async () => {
+    const seenModels: string[] = []
+    const adapters = Object.fromEntries(["characters", "story", "style"].map((skill) => [
+      skill,
+      {
+        skill,
+        async runChunk({ chunk, llmConfig }) {
+          seenModels.push(llmConfig.model)
+          return { result: { chunkId: chunk.id }, evidence: [] }
+        },
+        async aggregate() {
+          return { skill }
+        },
+        async publish() {
+          return `${skill}.json`
+        },
+      } satisfies AnalysisSkillAdapter,
+    ])) as Record<AnalysisSkill, AnalysisSkillAdapter>
+    const savedResults = new Map<string, unknown>()
+    const scheduler = createAnalysisScheduler({
+      adapters,
+      llmConfig: (target) => ({ model: target.modelKey ?? "default-model" }) as LlmConfig,
+      saveTask: vi.fn(async () => {}),
+      saveChunk: vi.fn(async () => {}),
+      saveCompletedChunk: vi.fn(async (_bookPath, chunk, result) => {
+        const resultPath = `${chunk.skill}-${chunk.id}.result.json`
+        savedResults.set(resultPath, result)
+        return { ...chunk, status: "completed", resultPath, completedAt: 10, updatedAt: 10 }
+      }),
+      loadChunkResult: vi.fn(async (chunk) => chunk.resultPath ? savedResults.get(chunk.resultPath) ?? null : null),
+      now: () => 10,
+    })
+    const configured = { ...task(["style"], ["chunk-1"]), modelKey: "openai/gpt-4o-mini" }
+    scheduler.initialize([configured], chunks(["style"], 1))
+
+    await scheduler.continueTask("task-1")
+    await scheduler.whenIdle()
+
+    expect(seenModels).toEqual(["openai/gpt-4o-mini"])
+  })
 })

+ 8 - 4
src/lib/novel/book-analysis/analysis-scheduler.ts

@@ -44,7 +44,11 @@ export interface AnalysisScheduler {
 
 interface AnalysisSchedulerOptions {
   adapters: Record<AnalysisSkill, AnalysisSkillAdapter>
-  llmConfig: LlmConfig | (() => LlmConfig)
+  /**
+   * 模型解析。传函数时会拿到当前任务,用于让每个任务用回自己选定的 `task.modelKey`;
+   * 暂停/继续和并行任务都靠这个保证模型不会串。
+   */
+  llmConfig: LlmConfig | ((task: BookAnalysisPipelineTask) => LlmConfig)
   concurrency?: number
   saveTask?: typeof saveAnalysisTask
   saveChunk?: typeof saveAnalysisChunk
@@ -162,8 +166,8 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
     if (changed) notify()
   }
 
-  function resolveLlmConfig(): LlmConfig {
-    return typeof options.llmConfig === "function" ? options.llmConfig() : options.llmConfig
+  function resolveLlmConfig(task: BookAnalysisPipelineTask): LlmConfig {
+    return typeof options.llmConfig === "function" ? options.llmConfig(task) : options.llmConfig
   }
 
   async function acquirePermit(): Promise<void> {
@@ -192,7 +196,7 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
       skill,
       bookPath: task.bookPath,
       projectPath: task.projectPath,
-      llmConfig: resolveLlmConfig(),
+      llmConfig: resolveLlmConfig(task),
     }
   }
 

+ 247 - 0
src/lib/novel/book-analysis/chapter-meta.spec.ts

@@ -0,0 +1,247 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import {
+  buildChapterMetaEntry,
+  computeDialogueRatio,
+  estimateSceneCount,
+  excerptChapterBody,
+  formatChapterMetaForMarkdown,
+  loadChapterMeta,
+  scoreChapterMeta,
+  selectRelevantChapters,
+  upsertChapterMeta,
+  type ChapterMetaEntry,
+  type ChapterMetaStoreIo,
+} from "./chapter-meta"
+
+const BOOK_PATH = "E:/Novel/book-analysis/book-1"
+
+function memoryIo(): { io: ChapterMetaStoreIo; mem: Map<string, string> } {
+  const mem = new Map<string, string>()
+  return {
+    mem,
+    io: {
+      createDirectory: vi.fn(async () => {}),
+      fileExists: vi.fn(async (path: string) => mem.has(path)),
+      readFile: vi.fn(async (path: string) => {
+        if (!mem.has(path)) throw new Error("ENOENT")
+        return mem.get(path)!
+      }),
+      writeFileAtomic: vi.fn(async (path: string, content: string) => { mem.set(path, content) }),
+    },
+  }
+}
+
+function entry(overrides: Partial<ChapterMetaEntry> = {}): ChapterMetaEntry {
+  return {
+    chapterId: "ch-0001",
+    order: 1,
+    title: "宗门试炼",
+    wordCount: 1200,
+    hookType: "冲突式",
+    structurePattern: "冲突章",
+    sceneCount: 2,
+    topicTags: ["宗门试炼"],
+    dialogueRatio: 0.4,
+    updatedAt: 1,
+    ...overrides,
+  }
+}
+
+describe("estimateSceneCount", () => {
+  it("按连续空行切场景", () => {
+    expect(estimateSceneCount("场景一。\n\n\n场景二。")).toBe(2)
+  })
+
+  it("按分隔符行切场景", () => {
+    expect(estimateSceneCount("场景一。\n***\n场景二。")).toBe(2)
+  })
+
+  it("单场景返回 1", () => {
+    expect(estimateSceneCount("只有一段。\n紧接着一段。")).toBe(1)
+  })
+
+  it("空正文返回 0", () => {
+    expect(estimateSceneCount("   ")).toBe(0)
+  })
+})
+
+describe("computeDialogueRatio", () => {
+  it("统计含引号段落的占比", () => {
+    expect(computeDialogueRatio("“你来了。”\n他点头。")).toBe(0.5)
+  })
+
+  it("无段落时返回 0", () => {
+    expect(computeDialogueRatio("")).toBe(0)
+  })
+
+  it("识别直角引号", () => {
+    expect(computeDialogueRatio("「你来了。」")).toBe(1)
+  })
+})
+
+describe("buildChapterMetaEntry", () => {
+  it("脚本字段现算,模型字段取标注", () => {
+    const result = buildChapterMetaEntry({
+      chapterId: "ch-0001",
+      order: 3,
+      title: "试炼",
+      body: "“来了。”\n他点头。\n\n\n第二天。",
+      annotation: { chapterId: "ch-0001", hookType: "对白式", structurePattern: "推进章", topicTags: ["试炼"] },
+      now: 99,
+    })
+
+    expect(result.hookType).toBe("对白式")
+    expect(result.topicTags).toEqual(["试炼"])
+    expect(result.sceneCount).toBe(2)
+    expect(result.dialogueRatio).toBeGreaterThan(0)
+    expect(result.wordCount).toBeGreaterThan(0)
+    expect(result.updatedAt).toBe(99)
+  })
+
+  it("没有标注时模型字段留空而不是瞎猜", () => {
+    const result = buildChapterMetaEntry({
+      chapterId: "ch-0001", order: 1, title: "", body: "他走了。", now: 1,
+    })
+    expect(result.hookType).toBe("")
+    expect(result.structurePattern).toBe("")
+    expect(result.topicTags).toEqual([])
+    expect(result.sceneCount).toBe(1)
+  })
+})
+
+describe("chapter meta 存储", () => {
+  let ctx = memoryIo()
+  beforeEach(() => { ctx = memoryIo() })
+
+  it("文件不存在时返回空集合", async () => {
+    const collection = await loadChapterMeta(BOOK_PATH, ctx.io)
+    expect(collection.entries).toEqual([])
+    expect(collection.bookId).toBe("book-1")
+  })
+
+  it("写入后可读回,并按 order 排序", async () => {
+    await upsertChapterMeta(BOOK_PATH, [entry({ chapterId: "ch-0002", order: 2 }), entry()], ctx.io)
+    const collection = await loadChapterMeta(BOOK_PATH, ctx.io)
+    expect(collection.entries.map((item) => item.chapterId)).toEqual(["ch-0001", "ch-0002"])
+  })
+
+  it("按 chapterId 覆盖,不影响其他章", async () => {
+    await upsertChapterMeta(BOOK_PATH, [entry(), entry({ chapterId: "ch-0002", order: 2 })], ctx.io)
+    await upsertChapterMeta(BOOK_PATH, [entry({ hookType: "悬念式" })], ctx.io)
+
+    const collection = await loadChapterMeta(BOOK_PATH, ctx.io)
+    expect(collection.entries).toHaveLength(2)
+    expect(collection.entries[0].hookType).toBe("悬念式")
+    expect(collection.entries[1].hookType).toBe("冲突式")
+  })
+
+  it("文件内容损坏时返回空集合而不抛错", async () => {
+    ctx.mem.set(`${BOOK_PATH}/analysis/chapter-meta.json`, "{ 不是 JSON")
+    expect((await loadChapterMeta(BOOK_PATH, ctx.io)).entries).toEqual([])
+  })
+
+  it("版本号不符时返回空集合", async () => {
+    ctx.mem.set(`${BOOK_PATH}/analysis/chapter-meta.json`, JSON.stringify({ version: 99, bookId: "b", entries: [{}] }))
+    expect((await loadChapterMeta(BOOK_PATH, ctx.io)).entries).toEqual([])
+  })
+})
+
+describe("scoreChapterMeta", () => {
+  it("题材标签命中权重最高", () => {
+    const tagHit = scoreChapterMeta(entry({ title: "无关", hookType: "", structurePattern: "" }), ["宗门试炼"])
+    const titleHit = scoreChapterMeta(entry({ topicTags: [], hookType: "", structurePattern: "" }), ["宗门试炼"])
+    expect(tagHit).toBeGreaterThan(titleHit)
+  })
+
+  it("完全不命中得 0", () => {
+    expect(scoreChapterMeta(entry(), ["炼丹炉"])).toBe(0)
+  })
+
+  it("空查询得 0", () => {
+    expect(scoreChapterMeta(entry(), [])).toBe(0)
+  })
+})
+
+describe("selectRelevantChapters", () => {
+  const entries = [
+    entry({ chapterId: "ch-0001", order: 1, topicTags: ["宗门试炼"], title: "试炼开始" }),
+    entry({ chapterId: "ch-0002", order: 2, topicTags: ["赶路"], title: "山路" }),
+    entry({ chapterId: "ch-0003", order: 3, topicTags: ["宗门试炼"], title: "试炼决胜" }),
+    entry({ chapterId: "ch-0004", order: 4, topicTags: ["炼丹"], title: "炉火" }),
+  ]
+
+  it("优先返回题材命中的章节", () => {
+    const picked = selectRelevantChapters(entries, "写一场宗门试炼的对决", 2)
+    expect(picked.map((item) => item.chapterId).sort()).toEqual(["ch-0001", "ch-0003"])
+  })
+
+  it("命中超额时取 order 更大的(更近的)", () => {
+    const picked = selectRelevantChapters(entries, "宗门试炼", 1)
+    expect(picked[0].chapterId).toBe("ch-0003")
+  })
+
+  it("命中不足时补齐到 limit", () => {
+    const picked = selectRelevantChapters(entries, "炼丹", 3)
+    expect(picked).toHaveLength(3)
+    expect(picked[0].chapterId).toBe("ch-0004")
+  })
+
+  it("完全不命中时也返回 limit 条", () => {
+    const picked = selectRelevantChapters(entries, "外星人入侵", 2)
+    expect(picked).toHaveLength(2)
+  })
+
+  it("章节数少于 limit 时返回全部且不重复", () => {
+    const picked = selectRelevantChapters(entries.slice(0, 2), "宗门试炼", 5)
+    expect(picked).toHaveLength(2)
+    expect(new Set(picked.map((item) => item.chapterId)).size).toBe(2)
+  })
+
+  it("空输入或 limit 为 0 时返回空", () => {
+    expect(selectRelevantChapters([], "试炼", 5)).toEqual([])
+    expect(selectRelevantChapters(entries, "试炼", 0)).toEqual([])
+  })
+})
+
+describe("excerptChapterBody", () => {
+  it("跳过 markdown 标题行", () => {
+    expect(excerptChapterBody("# 第一章\n他走了。", 100)).toBe("他走了。")
+  })
+
+  it("按句截断而不是切在句中", () => {
+    const excerpt = excerptChapterBody("他走了。她没动。真的吗?", 10)
+    expect(excerpt).toBe("他走了。她没动。")
+  })
+
+  it("正文短于上限时全量返回", () => {
+    expect(excerptChapterBody("他走了。", 100)).toBe("他走了。")
+  })
+
+  it("首句就超限时硬截断而不是返回空", () => {
+    const excerpt = excerptChapterBody(`${"很长".repeat(50)}。`, 10)
+    expect(excerpt).toHaveLength(10)
+  })
+
+  it("空正文返回空串", () => {
+    expect(excerptChapterBody("   ", 100)).toBe("")
+  })
+})
+
+describe("formatChapterMetaForMarkdown", () => {
+  it("渲染表格", () => {
+    const markdown = formatChapterMetaForMarkdown([entry()])
+    expect(markdown).toContain("| 章节 | 标题 |")
+    expect(markdown).toContain("ch-0001")
+    expect(markdown).toContain("宗门试炼")
+    expect(markdown).toContain("40.0%")
+  })
+
+  it("空标注给出占位说明", () => {
+    expect(formatChapterMetaForMarkdown([])).toBe("(无章节标注)")
+  })
+
+  it("缺字段时用占位符而不是留空单元格", () => {
+    const markdown = formatChapterMetaForMarkdown([entry({ title: "", hookType: "", topicTags: [] })])
+    expect(markdown).toContain("—")
+  })
+})

+ 259 - 0
src/lib/novel/book-analysis/chapter-meta.ts

@@ -0,0 +1,259 @@
+/**
+ * 章节元数据标注(feature/writing-dna)
+ *
+ * 对应 writing-dna-skill 的 `_meta/` 目录:给每一章打上 hook 类型、结构类型、
+ * 场景数、题材标签。这是"写作前读 5 篇体裁题材最接近的原文"能落地的前提——
+ * 没有它就只能注入固定 samples,无法按当前要写的章去检索。
+ *
+ * 落盘:<bookPath>/analysis/chapter-meta.json
+ * 标注来源:能脚本算的(字数、场景数、对白占比)就脚本算,
+ * 只有 hook 类型 / 结构类型 / 题材标签需要模型判断,且折进 L2 结构分析同一次调用,不额外加调用。
+ */
+import { createDirectory, fileExists, readFile, writeFileAtomic } from "@/commands/fs"
+import { joinPath, normalizePath } from "@/lib/path-utils"
+import { splitSentences } from "./style-metrics"
+
+export interface ChapterMetaEntry {
+  chapterId: string
+  order: number
+  title: string
+  wordCount: number
+  /** 开场 hook 类型:冲突式 / 场景式 / 对白式 / 悬念式 / 叙述式 / 问题式 */
+  hookType: string
+  /** 章节结构类型:推进章 / 冲突章 / 过渡章 / 爆点章 / 信息章 */
+  structurePattern: string
+  /** 本章场景数(脚本估算,按空行与场景分隔符切) */
+  sceneCount: number
+  /** 题材标签,用于按题材检索 */
+  topicTags: string[]
+  /** 含引号对白的段落占比(脚本算) */
+  dialogueRatio: number
+  updatedAt: number
+}
+
+export interface ChapterMetaCollection {
+  version: 1
+  bookId: string
+  entries: ChapterMetaEntry[]
+  updatedAt: number
+}
+
+/** 模型在 L2 调用里顺带返回的标注(只含它才能判断的字段)。 */
+export interface ChapterMetaAnnotation {
+  chapterId: string
+  hookType: string
+  structurePattern: string
+  topicTags: string[]
+}
+
+export interface ChapterMetaStoreIo {
+  createDirectory(path: string): Promise<void>
+  fileExists(path: string): Promise<boolean>
+  readFile(path: string): Promise<string>
+  writeFileAtomic(path: string, contents: string): Promise<void>
+}
+
+const defaultIo: ChapterMetaStoreIo = { createDirectory, fileExists, readFile, writeFileAtomic }
+
+/** 场景分隔:连续空行,或整行只有分隔符号。 */
+const SCENE_SEPARATOR = /\n\s*\n\s*\n|\n\s*(?:[**·•]{3,}|[-—─]{3,}|={3,})\s*\n/
+
+function normalized(path: string): string {
+  return normalizePath(path).replace(/\/+$/, "")
+}
+
+function metaPath(bookPath: string): string {
+  return normalized(joinPath(bookPath, "analysis", "chapter-meta.json"))
+}
+
+function analysisDir(bookPath: string): string {
+  return normalized(joinPath(bookPath, "analysis"))
+}
+
+function bookIdFromPath(bookPath: string): string {
+  return normalized(bookPath).split("/").pop() || "unknown-book"
+}
+
+function emptyCollection(bookPath: string): ChapterMetaCollection {
+  return { version: 1, bookId: bookIdFromPath(bookPath), entries: [], updatedAt: 0 }
+}
+
+function isCollection(value: unknown): value is ChapterMetaCollection {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return false
+  const candidate = value as Partial<ChapterMetaCollection>
+  return candidate.version === 1 && typeof candidate.bookId === "string" && Array.isArray(candidate.entries)
+}
+
+/** 脚本估算场景数:至少 1。 */
+export function estimateSceneCount(body: string): number {
+  const text = body.trim()
+  if (!text) return 0
+  return text.split(SCENE_SEPARATOR).filter((part) => part.trim().length > 0).length || 1
+}
+
+/** 脚本算含对白段落占比。 */
+export function computeDialogueRatio(body: string): number {
+  const paragraphs = body.split(/\r?\n+/).map((item) => item.trim()).filter(Boolean)
+  if (paragraphs.length === 0) return 0
+  const withDialogue = paragraphs.filter((item) => /[“”「」『』]/.test(item)).length
+  return Number((withDialogue / paragraphs.length).toFixed(4))
+}
+
+/**
+ * 合成一条章节元数据:脚本字段现算,模型字段取标注,缺标注时留空字符串。
+ * 留空而不是瞎猜——检索时空标签只是匹配不上,不会污染结果。
+ */
+export function buildChapterMetaEntry(input: {
+  chapterId: string
+  order: number
+  title: string
+  body: string
+  annotation?: ChapterMetaAnnotation
+  now: number
+}): ChapterMetaEntry {
+  const body = input.body.trim()
+  return {
+    chapterId: input.chapterId,
+    order: input.order,
+    title: input.title,
+    wordCount: body.length,
+    hookType: input.annotation?.hookType ?? "",
+    structurePattern: input.annotation?.structurePattern ?? "",
+    sceneCount: estimateSceneCount(body),
+    topicTags: input.annotation?.topicTags ?? [],
+    dialogueRatio: computeDialogueRatio(body),
+    updatedAt: input.now,
+  }
+}
+
+export async function loadChapterMeta(
+  bookPath: string,
+  io: ChapterMetaStoreIo = defaultIo,
+): Promise<ChapterMetaCollection> {
+  const path = metaPath(bookPath)
+  if (!(await io.fileExists(path))) return emptyCollection(bookPath)
+  try {
+    const parsed = JSON.parse(await io.readFile(path)) as unknown
+    return isCollection(parsed) ? parsed : emptyCollection(bookPath)
+  } catch {
+    return emptyCollection(bookPath)
+  }
+}
+
+/** 按 chapterId 覆盖写入;未涉及的章节保持不变。 */
+export async function upsertChapterMeta(
+  bookPath: string,
+  incoming: ChapterMetaEntry[],
+  io: ChapterMetaStoreIo = defaultIo,
+): Promise<ChapterMetaCollection> {
+  const current = await loadChapterMeta(bookPath, io)
+  const byId = new Map(current.entries.map((item) => [item.chapterId, item]))
+  for (const item of incoming) byId.set(item.chapterId, item)
+  const next: ChapterMetaCollection = {
+    ...current,
+    entries: [...byId.values()].sort((a, b) => a.order - b.order),
+    updatedAt: Date.now(),
+  }
+  await io.createDirectory(analysisDir(bookPath))
+  await io.writeFileAtomic(metaPath(bookPath), JSON.stringify(next, null, 2))
+  return next
+}
+
+function tokenizeQuery(query: string): string[] {
+  return query
+    .toLowerCase()
+    .split(/[\s,,。、;;::!!??"'“”「」()()\[\]【】/\\|-]+/)
+    .map((item) => item.trim())
+    .filter((item) => item.length >= 2)
+}
+
+/**
+ * 按当前写作任务描述给章节打分:题材标签命中权重最高,其次标题命中,
+ * 再其次 hook / 结构类型命中。全都不命中时得 0 分。
+ */
+export function scoreChapterMeta(entry: ChapterMetaEntry, queryTokens: string[]): number {
+  if (queryTokens.length === 0) return 0
+  let score = 0
+  const tags = entry.topicTags.map((item) => item.toLowerCase())
+  const title = entry.title.toLowerCase()
+  const structural = `${entry.hookType}${entry.structurePattern}`.toLowerCase()
+  for (const token of queryTokens) {
+    if (tags.some((tag) => tag.includes(token) || token.includes(tag))) score += 3
+    if (title.includes(token)) score += 2
+    if (structural.includes(token)) score += 1
+  }
+  return score
+}
+
+/**
+ * 挑出与当前写作任务最接近的若干章。
+ * 对齐 writing-dna-skill 的取法:命中项超额时取 order 最大(最近)的,
+ * 命中不足时用篇幅达标的章节补齐,保证总是给够条数。
+ */
+export function selectRelevantChapters(
+  entries: ChapterMetaEntry[],
+  query: string,
+  limit: number,
+): ChapterMetaEntry[] {
+  if (entries.length === 0 || limit <= 0) return []
+  const tokens = tokenizeQuery(query)
+  const scored = entries
+    .map((entry) => ({ entry, score: scoreChapterMeta(entry, tokens) }))
+    .filter((item) => item.score > 0)
+    .sort((a, b) => b.score - a.score || b.entry.order - a.entry.order)
+    .slice(0, limit)
+    .map((item) => item.entry)
+
+  if (scored.length >= limit) return scored
+
+  const taken = new Set(scored.map((item) => item.chapterId))
+  const median = [...entries].sort((a, b) => a.wordCount - b.wordCount)[Math.floor(entries.length / 2)]
+  const minWordCount = Math.min(median?.wordCount ?? 0, 800)
+  const filler = entries
+    .filter((entry) => !taken.has(entry.chapterId) && entry.wordCount >= minWordCount)
+    .sort((a, b) => b.order - a.order)
+  const fallback = entries.filter((entry) => !taken.has(entry.chapterId)).sort((a, b) => b.order - a.order)
+  for (const entry of [...filler, ...fallback]) {
+    if (scored.length >= limit) break
+    if (taken.has(entry.chapterId)) continue
+    taken.add(entry.chapterId)
+    scored.push(entry)
+  }
+  return scored
+}
+
+/** 从章节正文里截一段代表性片段:跳过标题行,取前若干句凑到 limit 字。 */
+export function excerptChapterBody(body: string, limit: number): string {
+  const text = body.replace(/^#{1,6}\s+[^\n]*\n/, "").trim()
+  if (!text) return ""
+  if (text.length <= limit) return text
+  const sentences = splitSentences(text)
+  let excerpt = ""
+  for (const sentence of sentences) {
+    if (excerpt.length + sentence.length > limit) break
+    excerpt += sentence
+  }
+  return excerpt || text.slice(0, limit)
+}
+
+/** 章节元数据的可读渲染,写进 语言DNA.md / 章节结构模板.md 的附录。 */
+export function formatChapterMetaForMarkdown(entries: ChapterMetaEntry[]): string {
+  if (entries.length === 0) return "(无章节标注)"
+  const lines = [
+    "| 章节 | 标题 | 字数 | hook | 结构 | 场景数 | 对白占比 | 题材标签 |",
+    "| - | - | - | - | - | - | - | - |",
+  ]
+  for (const entry of entries) {
+    lines.push([
+      entry.chapterId,
+      entry.title || "—",
+      String(entry.wordCount),
+      entry.hookType || "—",
+      entry.structurePattern || "—",
+      String(entry.sceneCount),
+      `${(entry.dialogueRatio * 100).toFixed(1)}%`,
+      entry.topicTags.length > 0 ? entry.topicTags.join("、") : "—",
+    ].join(" | ").replace(/^/, "| ").concat(" |"))
+  }
+  return lines.join("\n")
+}

+ 3 - 1
src/lib/novel/book-analysis/library-state.ts

@@ -5,6 +5,7 @@ import { joinPath, normalizePath } from "@/lib/path-utils"
 import { isSameBookAnalysisCharacterAura } from "./aura-match"
 import { loadRecognizedCharacters } from "./recognized-character-store"
 import { findCharacterForSkillFile } from "./skill-character-match"
+import { migrateStyleProfile } from "./style-profile-schema"
 import type {
   BookAnalysisMetadata,
   BookAnalysisResult,
@@ -172,7 +173,8 @@ export async function loadBookAnalysisLibraryState(projectPath: string): Promise
       ? storedRecognizedCharacters
       : recognizedFromExtractedCharacters(entry.path, characters)
     const skills = await loadSkills(entry.path, metadata, characters)
-    const styleProfile = await readJson<BookStyleProfile>(joinPath(entry.path, "style-profile.json"))
+    const storedStyleProfile = await readJson<BookStyleProfile>(joinPath(entry.path, "style-profile.json"))
+    const styleProfile = storedStyleProfile ? migrateStyleProfile(storedStyleProfile) : null
     const analysisManifest = await readJson<BookAnalysisModuleManifest>(joinPath(entry.path, "analysis", "manifest.json"))
     const evidenceCollection = await readJson<{ snippets?: AnalysisEvidenceSnippet[] }>(joinPath(entry.path, "analysis", "evidence.json"))
     const styleStatus: BookStyleStatus =

+ 2 - 1
src/lib/novel/book-analysis/result-loader.ts

@@ -7,6 +7,7 @@
 import { readFile, listDirectory } from "@/commands/fs"
 import { joinPath } from "@/lib/path-utils"
 import { findCharacterForSkillFile } from "./skill-character-match"
+import { migrateStyleProfile } from "./style-profile-schema"
 import type {
   BookAnalysisResult,
   BookAnalysisMetadata,
@@ -19,7 +20,7 @@ import type {
 async function loadStyleProfile(bookPath: string): Promise<BookStyleProfile | null> {
   try {
     const raw = await readFile(joinPath(bookPath, "style-profile.json"))
-    return JSON.parse(raw) as BookStyleProfile
+    return migrateStyleProfile(JSON.parse(raw) as BookStyleProfile)
   } catch {
     return null
   }

+ 451 - 54
src/lib/novel/book-analysis/style-analysis-adapter.spec.ts

@@ -1,9 +1,12 @@
 import { describe, expect, it, vi } from "vitest"
 import type { LlmConfig } from "@/stores/wiki-store"
-import type { AnalysisSkill, BookAnalysisPipelineTask } from "./analysis-pipeline-types"
-import { createStyleAnalysisAdapter } from "./style-analysis-adapter"
+import type { AnalysisChunkRecord, AnalysisSkill, BookAnalysisPipelineTask } from "./analysis-pipeline-types"
+import {
+  createStyleAnalysisAdapter,
+  type StyleAnalysisChunkResult,
+} from "./style-analysis-adapter"
 
-function task(): BookAnalysisPipelineTask {
+function task(overrides: Partial<BookAnalysisPipelineTask> = {}): BookAnalysisPipelineTask {
   const module = (skill: AnalysisSkill) => ({
     skill,
     status: "pending" as const,
@@ -32,71 +35,465 @@ function task(): BookAnalysisPipelineTask {
     startedAt: 1,
     completedAt: null,
     updatedAt: 1,
+    ...overrides,
   }
 }
 
-describe("style analysis adapter", () => {
-  it("文风结果包含幽默、热血、视角、词汇和证据", async () => {
-    const adapter = createStyleAnalysisAdapter({
-      readFile: vi.fn(async () => "---\nid: ch-0001\n---\n这是代表性正文。"),
-      loadMetadata: vi.fn(async () => ({
-        title: "测试作品",
-        totalChapters: 1,
-        totalWords: 100,
-        sourceType: "file" as const,
-        createdAt: 1,
-        updatedAt: 1,
+function chunk(overrides: Partial<AnalysisChunkRecord> = {}): AnalysisChunkRecord {
+  return {
+    version: 1,
+    id: "chunk-0001-0001",
+    taskId: "task-1",
+    skill: "style",
+    chapterIds: ["ch-0001"],
+    startOrder: 1,
+    endOrder: 1,
+    wordCount: 100,
+    status: "running",
+    attempts: 1,
+    resultPath: null,
+    error: null,
+    startedAt: 1,
+    completedAt: null,
+    updatedAt: 1,
+    ...overrides,
+  }
+}
+
+const metadata = {
+  title: "测试作品",
+  totalChapters: 1,
+  totalWords: 100,
+  sourceType: "file" as const,
+  createdAt: 1,
+  updatedAt: 1,
+}
+
+const CHAPTER_BODY = "这是代表性正文。他没有回头。\n\n“别过来。”她退了一步。"
+
+const languageResponse = JSON.stringify({
+  languageDna: "短句为主,平均句长偏低。",
+  rhythmGuide: "一句一段用于转折。",
+  narrativeDensity: "推进快",
+  sentenceStyle: "短句",
+  rhetoricDensity: "比喻少",
+  dialogueStyle: "口语",
+  vocabularyPreferences: ["口语化动词"],
+  avoidPatterns: ["解释笑点"],
+})
+
+const structureResponse = JSON.stringify({
+  structurePatterns: "推进章:冲突式开场 → 转折 → 钩子。",
+  transitionStyle: "直接跳时间",
+  descriptionWeight: "描写少",
+  emotionRendering: "动作外显",
+  thematicHabits: "不点题",
+  samples: ["这是代表性正文。"],
+  chapterMeta: [{
+    chapterId: "ch-0001",
+    hookType: "冲突式",
+    structurePattern: "推进章",
+    topicTags: ["宗门试炼", "炼丹"],
+  }],
+  evidence: [{
+    chapterId: "ch-0001",
+    text: "这是代表性正文。",
+    tags: ["幽默对白"],
+    reason: "反差",
+    purpose: "对白节奏",
+  }],
+})
+
+const cognitiveResponse = JSON.stringify({
+  cognitiveFrame: "### 推进取舍(L3)\n能一句带过就不展开。",
+  narrativeVoice: "冷静叙述者",
+  pointOfView: "第三人称限知",
+  humorMechanisms: ["一本正经地补刀"],
+  highEnergyMechanisms: ["目标兑现前连续抬压"],
+})
+
+const integratedResponse = [
+  "## 语言特征",
+  "短句为主,平均句长 12 字。",
+  "",
+  "## 风格硬约束",
+  "1. 环境描写控制在 2 句以内,因为作者用动作带出场景。",
+  "2. 比喻只用日常喻体。",
+].join("\n")
+
+function makeAdapter(responses: string[], overrides: Record<string, unknown> = {}) {
+  const callModel = vi.fn(async () => responses.shift() ?? "")
+  const adapter = createStyleAnalysisAdapter({
+    readFile: vi.fn(async () => `---\nid: ch-0001\ntitle: 第一章 试炼\n---\n${CHAPTER_BODY}`),
+    loadMetadata: vi.fn(async () => metadata),
+    callModel,
+    now: () => 10,
+    fallbackDepth: () => "full",
+    ...overrides,
+  })
+  return { adapter, callModel }
+}
+
+function runChunkInput(inputTask: BookAnalysisPipelineTask) {
+  return {
+    task: inputTask,
+    skill: "style" as const,
+    bookPath: inputTask.bookPath,
+    projectPath: inputTask.projectPath,
+    llmConfig: {} as LlmConfig,
+    chunk: chunk({ taskId: inputTask.id }),
+    signal: new AbortController().signal,
+  }
+}
+
+describe("style analysis adapter · runChunk", () => {
+  it("full 深度跑三次 LLM,产出四层与旧维度", async () => {
+    const { adapter, callModel } = makeAdapter([languageResponse, structureResponse, cognitiveResponse])
+    const output = await adapter.runChunk(runChunkInput(task()))
+
+    expect(callModel).toHaveBeenCalledTimes(3)
+    expect(output.result.layers.languageDna).toBe("短句为主,平均句长偏低。")
+    expect(output.result.layers.rhythmGuide).toBe("一句一段用于转折。")
+    expect(output.result.layers.structurePatterns).toContain("推进章")
+    expect(output.result.layers.cognitiveFrame).toContain("推进取舍")
+    expect(output.result.legacy.humorMechanisms).toEqual(["一本正经地补刀"])
+    expect(output.result.legacy.highEnergyMechanisms).toEqual(["目标兑现前连续抬压"])
+    expect(output.result.legacy.pointOfView).toBe("第三人称限知")
+    expect(output.result.legacy.vocabularyPreferences).toEqual(["口语化动词"])
+  })
+
+  it("统计指标由脚本算出,不依赖模型输出", async () => {
+    const { adapter } = makeAdapter([languageResponse, structureResponse, cognitiveResponse])
+    const output = await adapter.runChunk(runChunkInput(task()))
+
+    expect(output.result.metrics.counts.chapters).toBe(1)
+    expect(output.result.metrics.counts.sentences).toBeGreaterThan(0)
+    expect(output.result.metrics.derived.avgSentenceChars).toBeGreaterThan(0)
+    expect(output.result.metrics.counts.dialogueParagraphs).toBe(1)
+  })
+
+  it("把统计数字喂进 L1 prompt", async () => {
+    const { adapter, callModel } = makeAdapter([languageResponse, structureResponse, cognitiveResponse])
+    await adapter.runChunk(runChunkInput(task()))
+
+    const firstPrompt = callModel.mock.calls[0][0][1].content as string
+    expect(firstPrompt).toContain("脚本统计结果")
+    expect(firstPrompt).toContain("平均句长")
+    expect(firstPrompt).toContain("不要重新估算")
+  })
+
+  it("章节标注合并模型判断与脚本字段", async () => {
+    const { adapter } = makeAdapter([languageResponse, structureResponse, cognitiveResponse])
+    const output = await adapter.runChunk(runChunkInput(task()))
+
+    expect(output.result.chapterMeta).toHaveLength(1)
+    const entry = output.result.chapterMeta[0]
+    expect(entry.chapterId).toBe("ch-0001")
+    expect(entry.title).toBe("第一章 试炼")
+    expect(entry.hookType).toBe("冲突式")
+    expect(entry.topicTags).toEqual(["宗门试炼", "炼丹"])
+    expect(entry.sceneCount).toBeGreaterThan(0)
+    expect(entry.dialogueRatio).toBeGreaterThan(0)
+  })
+
+  it("从 L2 输出解析证据片段并绑定章节顺序", async () => {
+    const { adapter } = makeAdapter([languageResponse, structureResponse, cognitiveResponse])
+    const output = await adapter.runChunk(runChunkInput(task()))
+
+    expect(output.evidence).toHaveLength(1)
+    expect(output.evidence[0].tags).toContain("幽默对白")
+    expect(output.evidence[0].chapterOrder).toBe(1)
+    expect(output.evidence[0].skill).toBe("style")
+  })
+
+  it("fast 深度只调一次 LLM,样本与证据由脚本截取", async () => {
+    const { adapter, callModel } = makeAdapter([languageResponse])
+    const output = await adapter.runChunk(runChunkInput(task({ styleDepth: "fast" })))
+
+    expect(callModel).toHaveBeenCalledTimes(1)
+    expect(output.result.layers.languageDna).toBeTruthy()
+    expect(output.result.layers.structurePatterns).toBe("")
+    expect(output.result.layers.cognitiveFrame).toBe("")
+    expect(output.result.samples.length).toBeGreaterThan(0)
+    expect(output.evidence[0].tags).toContain("脚本抽取")
+    // fast 模式没有模型标注,但脚本字段仍要落齐
+    expect(output.result.chapterMeta[0].hookType).toBe("")
+    expect(output.result.chapterMeta[0].sceneCount).toBeGreaterThan(0)
+  })
+
+  it("任务上的 styleDepth 覆盖兜底深度", async () => {
+    const { adapter, callModel } = makeAdapter(
+      [languageResponse, structureResponse, cognitiveResponse],
+      { fallbackDepth: () => "fast" },
+    )
+    await adapter.runChunk(runChunkInput(task({ styleDepth: "full" })))
+
+    expect(callModel).toHaveBeenCalledTimes(3)
+  })
+
+  it("任务没写 styleDepth 时用兜底深度(兼容旧任务)", async () => {
+    const { adapter, callModel } = makeAdapter([languageResponse], { fallbackDepth: () => "fast" })
+    await adapter.runChunk(runChunkInput(task()))
+
+    expect(callModel).toHaveBeenCalledTimes(1)
+  })
+
+  it("模型返回非 JSON 时不抛错,走空值降级", async () => {
+    const { adapter } = makeAdapter(["抱歉,我无法分析。", "也不行。", "还是不行。"])
+    const output = await adapter.runChunk(runChunkInput(task()))
+
+    expect(output.result.layers.languageDna).toBe("")
+    expect(output.result.metrics.counts.chapters).toBe(1)
+    expect(output.result.samples.length).toBeGreaterThan(0)
+  })
+
+  it("章节正文为空时报错", async () => {
+    const { adapter } = makeAdapter([languageResponse], {
+      readFile: vi.fn(async () => "---\nid: ch-0001\n---\n"),
+    })
+    await expect(adapter.runChunk(runChunkInput(task()))).rejects.toThrow("正文为空")
+  })
+})
+
+function chunkResult(overrides: Partial<StyleAnalysisChunkResult> = {}): StyleAnalysisChunkResult {
+  return {
+    metrics: {
+      schemaVersion: 1,
+      segmenterAvailable: true,
+      counts: {
+        chapters: 1, chars: 100, latinChars: 0, sentences: 10, shortSentences: 8,
+        longSentences: 1, sentenceCharTotal: 100, paragraphs: 5, oneSentenceParagraphs: 2,
+        longParagraphs: 0, dialogueParagraphs: 2,
+        punctuation: {
+          dash: 1, ellipsis: 0, quote: 4, exclamation: 1,
+          question: 0, comma: 10, semicolon: 0, colon: 0,
+        },
+      },
+      derived: {
+        avgSentenceChars: 10, shortSentenceRatio: 0.8, longSentenceRatio: 0.1,
+        avgParagraphChars: 20, avgParagraphSentences: 2, oneSentenceParagraphRatio: 0.4,
+        longParagraphRatio: 0, dialogueParagraphRatio: 0.4,
+        punctuationPerThousand: {
+          dash: 10, ellipsis: 0, quote: 40, exclamation: 10,
+          question: 0, comma: 100, semicolon: 0, colon: 0,
+        },
+        latinCharRatio: 0,
+      },
+      topWords: [{ word: "回头", count: 3 }],
+      topBigrams: [],
+      topSentenceOpeners: [],
+    },
+    layers: {
+      languageDna: "短句为主。",
+      structurePatterns: "推进章骨架。",
+      cognitiveFrame: "能一句带过就不展开。",
+      rhythmGuide: "一句一段用于转折。",
+    },
+    samples: ["片段一"],
+    chapterMeta: [{
+      chapterId: "ch-0001", order: 1, title: "第一章", wordCount: 100,
+      hookType: "冲突式", structurePattern: "推进章", sceneCount: 2,
+      topicTags: ["宗门试炼"], dialogueRatio: 0.4, updatedAt: 10,
+    }],
+    sampledChapterIds: ["ch-0001"],
+    legacy: {
+      narrativeDensity: "推进快", sentenceStyle: "短句", rhetoricDensity: "比喻少",
+      dialogueStyle: "口语", transitionStyle: "直接跳时间", descriptionWeight: "描写少",
+      emotionRendering: "动作外显", thematicHabits: "不点题", narrativeVoice: "冷静",
+      pointOfView: "第三人称限知",
+      vocabularyPreferences: ["口语化动词"], avoidPatterns: ["解释笑点"],
+      humorMechanisms: ["补刀"], highEnergyMechanisms: ["抬压"],
+    },
+    ...overrides,
+  }
+}
+
+function aggregateInput(chunks: StyleAnalysisChunkResult[]) {
+  const inputTask = task()
+  return {
+    task: inputTask,
+    skill: "style" as const,
+    bookPath: inputTask.bookPath,
+    projectPath: inputTask.projectPath,
+    llmConfig: {} as LlmConfig,
+    chunks,
+    signal: new AbortController().signal,
+  }
+}
+
+describe("style analysis adapter · aggregate", () => {
+  it("单分片只跑一次整合调用,不逐层汇总", async () => {
+    const { adapter, callModel } = makeAdapter([integratedResponse])
+    const result = await adapter.aggregate(aggregateInput([chunkResult()]))
+
+    expect(callModel).toHaveBeenCalledTimes(1)
+    expect(result.profile.schemaVersion).toBe(2)
+    expect(result.profile.layers?.languageDna).toBe("短句为主。")
+    expect(result.profile.integratedDna).toContain("语言特征")
+    expect(result.profile.constitution).toContain("环境描写控制在 2 句以内")
+  })
+
+  it("统计按原始计数相加后重新派生,不是比率取平均", async () => {
+    const heavy = chunkResult({
+      metrics: {
+        ...chunkResult().metrics,
+        counts: {
+          ...chunkResult().metrics.counts,
+          chapters: 1, chars: 100, sentences: 10, shortSentences: 0, longSentences: 10,
+        },
+      },
+    })
+    const { adapter } = makeAdapter([
+      "L1 合并", "L2 合并", "L3 合并", "L6 合并", integratedResponse,
+    ])
+    const result = await adapter.aggregate(aggregateInput([chunkResult(), heavy]))
+
+    expect(result.profile.metrics?.counts.chapters).toBe(2)
+    expect(result.profile.metrics?.counts.sentences).toBe(20)
+    expect(result.profile.metrics?.derived.shortSentenceRatio).toBe(0.4)
+    expect(result.profile.metrics?.derived.longSentenceRatio).toBe(0.55)
+  })
+
+  it("多分片时每层各汇总一次,再整合", async () => {
+    const second = chunkResult({
+      layers: {
+        languageDna: "另一种语言描述。",
+        structurePatterns: "另一种结构。",
+        cognitiveFrame: "另一种认知。",
+        rhythmGuide: "另一种节奏。",
+      },
+    })
+    const { adapter, callModel } = makeAdapter([
+      "L1 合并结果", "L2 合并结果", "L3 合并结果", "L6 合并结果", integratedResponse,
+    ])
+    const result = await adapter.aggregate(aggregateInput([chunkResult(), second]))
+
+    // 四层各一次 + 整合一次
+    expect(callModel).toHaveBeenCalledTimes(5)
+    expect(result.profile.layers?.languageDna).toBe("L1 合并结果")
+    expect(result.profile.layers?.rhythmGuide).toBe("L6 合并结果")
+  })
+
+  it("各分片同一层内容完全相同时不额外调模型", async () => {
+    const { adapter, callModel } = makeAdapter([integratedResponse])
+    await adapter.aggregate(aggregateInput([chunkResult(), chunkResult()]))
+    expect(callModel).toHaveBeenCalledTimes(1)
+  })
+
+  it("整合返回空内容时回落到通用宪法", async () => {
+    const { adapter } = makeAdapter([""])
+    const result = await adapter.aggregate(aggregateInput([chunkResult()]))
+    expect(result.profile.integratedDna).toBe("")
+    expect(result.profile.constitution).toContain("叙事优先")
+  })
+
+  it("章节标注按 chapterId 去重并按顺序排列", async () => {
+    const second = chunkResult({
+      chapterMeta: [{
+        chapterId: "ch-0002", order: 2, title: "第二章", wordCount: 200,
+        hookType: "场景式", structurePattern: "过渡章", sceneCount: 1,
+        topicTags: ["赶路"], dialogueRatio: 0.1, updatedAt: 10,
+      }],
+      sampledChapterIds: ["ch-0002"],
+    })
+    const { adapter } = makeAdapter([
+      "L1", "L2", "L3", "L6", integratedResponse,
+    ])
+    const result = await adapter.aggregate(aggregateInput([second, chunkResult()]))
+
+    expect(result.chapterMeta.map((item) => item.chapterId)).toEqual(["ch-0001", "ch-0002"])
+    expect(result.profile.sampledChapterIds).toEqual(["ch-0002", "ch-0001"])
+  })
+
+  it("没有已完成分片时报错", async () => {
+    const { adapter } = makeAdapter([])
+    await expect(adapter.aggregate(aggregateInput([]))).rejects.toThrow("没有已完成的文风区块")
+  })
+})
+
+describe("style analysis adapter · publish", () => {
+  it("落盘整合文档、四份分层产物、style.md 与章节标注", async () => {
+    const written = new Map<string, string>()
+    const saveChapterMeta = vi.fn(async () => ({
+      version: 1 as const, bookId: "book-1", entries: [], updatedAt: 10,
+    }))
+    const upsertPreset = vi.fn(async () => ({
+      id: "style-1", name: "测试作品 · 文风", sourceBook: "测试作品",
+      profile: chunkResult() as never, createdAt: 10, updatedAt: 10,
+    }))
+    const { adapter } = makeAdapter([integratedResponse], {
+      writeFile: vi.fn(async (path: string, contents: string) => { written.set(path, contents) }),
+      saveChapterMeta,
+      upsertPreset,
+      replaceEvidence: vi.fn(async () => ({
+        version: 1 as const, bookId: "book-1", snippets: [], updatedAt: 10,
+      })),
+      loadManifest: vi.fn(async () => null),
+      saveManifest: vi.fn(async () => undefined),
+      rebuildContextIndex: vi.fn(async () => undefined),
+    })
+
+    const aggregated = await adapter.aggregate(aggregateInput([chunkResult()]))
+    const inputTask = task()
+    const resultPath = await adapter.publish({
+      task: inputTask,
+      skill: "style",
+      bookPath: inputTask.bookPath,
+      projectPath: inputTask.projectPath,
+      llmConfig: {} as LlmConfig,
+      result: aggregated,
+      evidence: [],
+      signal: new AbortController().signal,
+    })
+
+    expect(resultPath).toContain("style-profile.json")
+    const names = [...written.keys()].map((path) => path.split("/").pop())
+    expect(names).toContain("style-profile.json")
+    expect(names).toContain("Writing-DNA.md")
+    expect(names).toContain("语言DNA.md")
+    expect(names).toContain("章节结构模板.md")
+    expect(names).toContain("叙事视角与认知框架.md")
+    expect(names).toContain("排版节奏指南.md")
+    expect(names).toContain("style.md")
+    expect(saveChapterMeta).toHaveBeenCalledTimes(1)
+    expect(upsertPreset).toHaveBeenCalledTimes(1)
+  })
+
+  it("语言DNA.md 带上脚本统计表", async () => {
+    const written = new Map<string, string>()
+    const { adapter } = makeAdapter([integratedResponse], {
+      writeFile: vi.fn(async (path: string, contents: string) => { written.set(path, contents) }),
+      saveChapterMeta: vi.fn(async () => ({
+        version: 1 as const, bookId: "book-1", entries: [], updatedAt: 10,
+      })),
+      upsertPreset: vi.fn(async () => ({
+        id: "style-1", name: "n", sourceBook: "测试作品",
+        profile: chunkResult() as never, createdAt: 10, updatedAt: 10,
       })),
-      callModel: vi.fn(async () => JSON.stringify({
-        narrativeDensity: "推进快",
-        constitution: "1. 使用短句加速",
-        humorMechanisms: ["一本正经地补刀"],
-        highEnergyMechanisms: ["目标兑现前连续抬压"],
-        pointOfView: "第三人称限知",
-        vocabularyPreferences: ["口语化动词"],
-        avoidPatterns: ["解释笑点"],
-        samples: ["这是代表性正文。"],
-        evidence: [{
-          chapterId: "ch-0001",
-          text: "这是代表性正文。",
-          tags: ["幽默对白"],
-          reason: "反差",
-          purpose: "对白节奏",
-        }],
+      replaceEvidence: vi.fn(async () => ({
+        version: 1 as const, bookId: "book-1", snippets: [], updatedAt: 10,
       })),
-      now: () => 10,
+      loadManifest: vi.fn(async () => null),
+      saveManifest: vi.fn(async () => undefined),
+      rebuildContextIndex: vi.fn(async () => undefined),
     })
+
+    const aggregated = await adapter.aggregate(aggregateInput([chunkResult()]))
     const inputTask = task()
-    const output = await adapter.runChunk({
+    await adapter.publish({
       task: inputTask,
       skill: "style",
       bookPath: inputTask.bookPath,
       projectPath: inputTask.projectPath,
       llmConfig: {} as LlmConfig,
-      chunk: {
-        version: 1,
-        id: "chunk-0001-0001",
-        taskId: inputTask.id,
-        skill: "style",
-        chapterIds: ["ch-0001"],
-        startOrder: 1,
-        endOrder: 1,
-        wordCount: 100,
-        status: "running",
-        attempts: 1,
-        resultPath: null,
-        error: null,
-        startedAt: 1,
-        completedAt: null,
-        updatedAt: 1,
-      },
+      result: aggregated,
+      evidence: [],
       signal: new AbortController().signal,
     })
 
-    expect(output.result.profile.humorMechanisms).toEqual(["一本正经地补刀"])
-    expect(output.result.profile.highEnergyMechanisms).toEqual(["目标兑现前连续抬压"])
-    expect(output.result.profile.pointOfView).toBe("第三人称限知")
-    expect(output.result.profile.vocabularyPreferences).toEqual(["口语化动词"])
-    expect(output.evidence[0].tags).toContain("幽默对白")
+    const languageDna = [...written.entries()].find(([path]) => path.endsWith("语言DNA.md"))?.[1] ?? ""
+    expect(languageDna).toContain("脚本统计")
+    expect(languageDna).toContain("平均句长")
+    expect(languageDna).toContain("短句为主。")
   })
 })

+ 353 - 97
src/lib/novel/book-analysis/style-analysis-adapter.ts

@@ -1,4 +1,17 @@
-import { readFile, writeFile } from "@/commands/fs"
+/**
+ * 文风 skill 的 pipeline adapter —— Writing DNA 分层蒸馏(feature/writing-dna)
+ *
+ * 单分片流程(full 深度):
+ *   1. computeStyleMetrics 脚本统计(不调 LLM)
+ *   2. L1 语言 + L6 排版节奏(喂统计数字让模型解读)
+ *   3. L2 章节结构(顺带返回 evidence 与每章标注)
+ *   4. L3-L5 认知框架
+ * aggregate 阶段合并各分片,再跑一次整合,产出 Writing-DNA.md 正文与风格硬约束。
+ *
+ * fast 深度只跑步骤 1-2,samples 与 evidence 改由脚本从原文截取,
+ * 供大部头先拿到可用结果;代价是没有 L2/L3-L5 分层。
+ */
+import { createDirectory, readFile, writeFile } from "@/commands/fs"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
 import { joinPath, normalizePath } from "@/lib/path-utils"
 import { upsertWritingStylePreset } from "@/lib/novel/writing-style-store"
@@ -6,26 +19,79 @@ import { loadMetadata } from "./analysis-engine"
 import { replaceAutomaticEvidence } from "./analysis-evidence-store"
 import { rebuildBookAnalysisContextIndex } from "./analysis-context-index"
 import { loadAnalysisManifest, saveAnalysisManifest } from "./analysis-pipeline-storage"
-import type { AnalysisEvidenceSnippet, BookAnalysisModuleManifest } from "./analysis-pipeline-types"
+import {
+  DEFAULT_STYLE_ANALYSIS_DEPTH,
+  type AnalysisEvidenceSnippet,
+  type BookAnalysisModuleManifest,
+  type StyleAnalysisDepth,
+} from "./analysis-pipeline-types"
 import type { AnalysisSkillAdapter } from "./analysis-skill-adapter"
 import {
-  buildStyleExtractionPrompt,
+  buildChapterMetaEntry,
+  excerptChapterBody,
+  upsertChapterMeta,
+  type ChapterMetaEntry,
+} from "./chapter-meta"
+import { computeStyleMetrics, mergeStyleMetrics, type StyleMetrics } from "./style-metrics"
+import { emptyWritingDnaLayers, INTEGRATED_DNA_FILE_NAME, WRITING_DNA_LAYERS } from "./style-profile-schema"
+import {
+  buildCognitiveFramePrompt,
+  buildDnaIntegrationPrompt,
+  buildLanguageAndRhythmPrompt,
+  buildLayerAggregatePrompt,
+  buildStructurePatternPrompt,
+  FALLBACK_STYLE_CONSTITUTION,
+  parseCognitiveFrameResult,
+  parseIntegratedDnaResult,
+  parseLanguageAndRhythmResult,
+  parseLayerAggregateResult,
+  parseStructurePatternResult,
   parseStyleEvidenceResult,
-  parseStyleProfileResult,
-} from "./style-prompts"
-import { styleProfileToMarkdown } from "./style-extraction-engine"
-import type { BookStyleProfile } from "./types"
+} from "./writing-dna-prompts"
+import { renderWritingDnaFiles, styleProfileToMarkdown } from "./writing-dna-render"
+import type { BookStyleProfile, WritingDnaLayers } from "./types"
 import { scheduleVerification } from "./verification-engine"
 import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 
-interface StyleAnalysisChunkResult {
-  raw: string
+/** fast 模式下脚本截取样本的条数与长度。 */
+const FAST_SAMPLE_COUNT = 4
+const FAST_SAMPLE_CHARS = 260
+
+/** 分片产出:必须可 JSON 序列化,scheduler 会落盘再读回来汇总。 */
+export interface StyleAnalysisChunkResult {
+  metrics: StyleMetrics
+  layers: WritingDnaLayers
+  samples: string[]
+  chapterMeta: ChapterMetaEntry[]
+  sampledChapterIds: string[]
+  legacy: {
+    narrativeDensity: string
+    sentenceStyle: string
+    rhetoricDensity: string
+    dialogueStyle: string
+    transitionStyle: string
+    descriptionWeight: string
+    emotionRendering: string
+    thematicHabits: string
+    narrativeVoice: string
+    pointOfView: string
+    vocabularyPreferences: string[]
+    avoidPatterns: string[]
+    humorMechanisms: string[]
+    highEnergyMechanisms: string[]
+  }
+}
+
+/** aggregate 产出:profile 之外还要把章节标注带到 publish 去落盘。 */
+export interface StyleAnalysisAggregateResult {
   profile: BookStyleProfile
+  chapterMeta: ChapterMetaEntry[]
 }
 
 interface StyleAnalysisAdapterDependencies {
   readFile: typeof readFile
   writeFile: typeof writeFile
+  createDirectory: typeof createDirectory
   loadMetadata: typeof loadMetadata
   callModel: (messages: ChatMessage[], llmConfig: Parameters<typeof streamChat>[0], signal: AbortSignal) => Promise<string>
   upsertPreset: typeof upsertWritingStylePreset
@@ -33,7 +99,10 @@ interface StyleAnalysisAdapterDependencies {
   loadManifest: typeof loadAnalysisManifest
   saveManifest: typeof saveAnalysisManifest
   rebuildContextIndex: typeof rebuildBookAnalysisContextIndex
+  saveChapterMeta: typeof upsertChapterMeta
   now: () => number
+  /** 任务上没写 styleDepth 时的兜底深度(旧任务与测试用) */
+  fallbackDepth: () => StyleAnalysisDepth
 }
 
 async function callStyleModel(
@@ -56,6 +125,7 @@ async function callStyleModel(
 const defaultDependencies: StyleAnalysisAdapterDependencies = {
   readFile,
   writeFile,
+  createDirectory,
   loadMetadata,
   callModel: callStyleModel,
   upsertPreset: upsertWritingStylePreset,
@@ -63,13 +133,20 @@ const defaultDependencies: StyleAnalysisAdapterDependencies = {
   loadManifest: loadAnalysisManifest,
   saveManifest: saveAnalysisManifest,
   rebuildContextIndex: rebuildBookAnalysisContextIndex,
+  saveChapterMeta: upsertChapterMeta,
   now: Date.now,
+  fallbackDepth: () => DEFAULT_STYLE_ANALYSIS_DEPTH,
 }
 
 function stripFrontmatter(content: string): string {
   return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim()
 }
 
+function frontmatterTitle(content: string): string {
+  const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ""
+  return frontmatter.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? ""
+}
+
 function mergeStrings(values: Array<string | undefined>): string {
   return [...new Set(values.map((value) => value?.trim()).filter((value): value is string => Boolean(value)))].join(";")
 }
@@ -78,127 +155,304 @@ function unique(values: string[]): string[] {
   return [...new Set(values.map((value) => value.trim()).filter(Boolean))]
 }
 
-function mergeStyleChunkProfiles(
-  chunks: StyleAnalysisChunkResult[],
-  evidenceIds: string[],
-  generatedAt: number,
-): BookStyleProfile {
-  const profiles = chunks.map((chunk) => chunk.profile)
+function emptyLegacy(): StyleAnalysisChunkResult["legacy"] {
   return {
-    schemaVersion: 1,
-    generatedAt,
-    sampledChapterIds: unique(profiles.flatMap((profile) => profile.sampledChapterIds)),
-    narrativeDensity: mergeStrings(profiles.map((profile) => profile.narrativeDensity)),
-    descriptionWeight: mergeStrings(profiles.map((profile) => profile.descriptionWeight)),
-    emotionRendering: mergeStrings(profiles.map((profile) => profile.emotionRendering)),
-    sentenceStyle: mergeStrings(profiles.map((profile) => profile.sentenceStyle)),
-    rhetoricDensity: mergeStrings(profiles.map((profile) => profile.rhetoricDensity)),
-    transitionStyle: mergeStrings(profiles.map((profile) => profile.transitionStyle)),
-    narrativeVoice: mergeStrings(profiles.map((profile) => profile.narrativeVoice)),
-    dialogueStyle: mergeStrings(profiles.map((profile) => profile.dialogueStyle)),
-    thematicHabits: mergeStrings(profiles.map((profile) => profile.thematicHabits)),
-    humorMechanisms: unique(profiles.flatMap((profile) => profile.humorMechanisms ?? [])),
-    highEnergyMechanisms: unique(profiles.flatMap((profile) => profile.highEnergyMechanisms ?? [])),
-    pointOfView: mergeStrings(profiles.map((profile) => profile.pointOfView)),
-    vocabularyPreferences: unique(profiles.flatMap((profile) => profile.vocabularyPreferences ?? [])),
-    avoidPatterns: unique(profiles.flatMap((profile) => profile.avoidPatterns ?? [])),
-    evidenceIds,
-    constitution: mergeStrings(profiles.map((profile) => profile.constitution)),
-    samples: unique(profiles.flatMap((profile) => profile.samples)).slice(0, 6),
+    narrativeDensity: "", sentenceStyle: "", rhetoricDensity: "", dialogueStyle: "",
+    transitionStyle: "", descriptionWeight: "", emotionRendering: "", thematicHabits: "",
+    narrativeVoice: "", pointOfView: "",
+    vocabularyPreferences: [], avoidPatterns: [], humorMechanisms: [], highEnergyMechanisms: [],
   }
 }
 
-function aggregatePrompt(chunks: StyleAnalysisChunkResult[]): string {
-  return [
-    "你是小说文风汇总专家。以下是同一用户所选章节范围内,各区块已经完成的文风 JSON。",
-    "请去重、消除偶发特征,并输出与原格式相同的单个 JSON。必须保留幽默机制、热血机制、叙事视角、词汇偏好、禁用写法和可执行风格宪法。",
-    "不得引入未分析章节,不得复述原作剧情。evidence 可以返回空数组,因为证据由区块结果单独保存。",
-    "",
-    ...chunks.map((chunk, index) => `区块 ${index + 1}:\n${chunk.raw}`),
-  ].join("\n\n")
+interface ChapterSample {
+  chapterId: string
+  order: number
+  title: string
+  body: string
+}
+
+/**
+ * 合并各分片的同一层文本。
+ * 只有一段非空内容时直接返回,多段才调模型去重——省一次调用。
+ */
+async function aggregateLayer(
+  layerLabel: string,
+  texts: string[],
+  bookTitle: string,
+  call: (prompt: string) => Promise<string>,
+): Promise<string> {
+  const filled = unique(texts)
+  if (filled.length === 0) return ""
+  if (filled.length === 1) return filled[0]
+  const raw = await call(buildLayerAggregatePrompt(layerLabel, filled, bookTitle))
+  return parseLayerAggregateResult(raw) || filled.join("\n\n")
 }
 
 export function createStyleAnalysisAdapter(
   overrides: Partial<StyleAnalysisAdapterDependencies> = {},
-): AnalysisSkillAdapter<StyleAnalysisChunkResult, BookStyleProfile> {
+): AnalysisSkillAdapter<StyleAnalysisChunkResult, StyleAnalysisAggregateResult> {
   const dependencies = { ...defaultDependencies, ...overrides }
+
   return {
     skill: "style",
+
     async runChunk({ task, bookPath, llmConfig, chunk, signal, onProgress }) {
-      onProgress?.({ stageLabel: "读取章节样本…", percentage: 10 })
+      const depth = task.styleDepth ?? dependencies.fallbackDepth()
+      onProgress?.({ stageLabel: "读取章节样本…", percentage: 5 })
       const metadata = await dependencies.loadMetadata(bookPath)
       if (!metadata) throw new Error("未找到作品元数据,无法分析文风")
-      const blocks: string[] = []
-      for (const chapterId of chunk.chapterIds) {
+
+      const samplesRead: ChapterSample[] = []
+      for (const [index, chapterId] of chunk.chapterIds.entries()) {
         const raw = await dependencies.readFile(joinPath(bookPath, "chapters", `${chapterId}.md`))
         const body = stripFrontmatter(raw).slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)
-        if (body) blocks.push(`【章节ID:${chapterId}】\n${body}`)
+        if (body) {
+          samplesRead.push({
+            chapterId,
+            order: chunk.startOrder + index,
+            title: frontmatterTitle(raw),
+            body,
+          })
+        }
+      }
+      if (samplesRead.length !== chunk.chapterIds.length) {
+        throw new Error("所选文风章节正文为空,请检查后重试")
       }
-      if (blocks.length !== chunk.chapterIds.length) throw new Error("所选文风章节正文为空,请检查后重试")
-      onProgress?.({ stageLabel: "正在调用模型分析文风…", percentage: 40 })
-      const raw = await dependencies.callModel([
-        { role: "system", content: "你是专业的小说文风分析助手。只输出用户要求的 JSON,不要解释。" },
-        { role: "user", content: buildStyleExtractionPrompt(blocks.join("\n\n———\n\n"), metadata.title) },
+
+      const sampleText = samplesRead
+        .map((item) => `【章节ID:${item.chapterId}${item.title ? ` · ${item.title}` : ""}】\n${item.body}`)
+        .join("\n\n———\n\n")
+
+      // 步骤 1:脚本统计,不调 LLM
+      onProgress?.({ stageLabel: "统计语言与排版指标…", percentage: 15 })
+      const metrics = computeStyleMetrics(samplesRead.map((item) => item.body))
+
+      const call = (prompt: string, system: string): Promise<string> => dependencies.callModel([
+        { role: "system", content: system },
+        { role: "user", content: prompt },
       ], llmConfig, signal)
-      onProgress?.({ stageLabel: "解析文风结果…", percentage: 85 })
-      const profile = parseStyleProfileResult(raw, chunk.chapterIds)
-      const evidence = parseStyleEvidenceResult(raw).flatMap((candidate, index): AnalysisEvidenceSnippet[] => {
-        if (!chunk.chapterIds.includes(candidate.chapterId)) return []
-        const chapterIndex = chunk.chapterIds.indexOf(candidate.chapterId)
-        return [{
-          version: 1,
-          id: `evidence-${task.id}-style-${chunk.id}-${index}`,
-          bookId: task.bookId,
-          skill: "style",
-          taskId: task.id,
-          chapterId: candidate.chapterId,
-          chapterOrder: chunk.startOrder + chapterIndex,
-          text: candidate.text.trim().slice(0, 500),
-          tags: candidate.tags,
-          reason: candidate.reason || "体现作品稳定的文风机制",
-          purpose: candidate.purpose || "文风仿写参考",
-          enabled: true,
-          userPinned: false,
-          createdAt: dependencies.now(),
-          updatedAt: dependencies.now(),
-        }]
-      })
+
+      const layers = emptyWritingDnaLayers()
+      const legacy = emptyLegacy()
+      let samples: string[] = []
+      let evidence: AnalysisEvidenceSnippet[] = []
+      let chapterMetaAnnotations: Awaited<ReturnType<typeof parseStructurePatternResult>>["chapterMeta"] = []
+
+      // 步骤 2:L1 语言 + L6 排版节奏
+      onProgress?.({ stageLabel: "解读语言与节奏(L1/L6)…", percentage: 30 })
+      const languageRaw = await call(
+        buildLanguageAndRhythmPrompt(metrics, sampleText, metadata.title),
+        "你是专业的小说文风分析助手。只输出用户要求的 JSON,不要解释。",
+      )
+      const language = parseLanguageAndRhythmResult(languageRaw)
+      layers.languageDna = language.languageDna
+      layers.rhythmGuide = language.rhythmGuide
+      legacy.narrativeDensity = language.narrativeDensity
+      legacy.sentenceStyle = language.sentenceStyle
+      legacy.rhetoricDensity = language.rhetoricDensity
+      legacy.dialogueStyle = language.dialogueStyle
+      legacy.vocabularyPreferences = language.vocabularyPreferences
+      legacy.avoidPatterns = language.avoidPatterns
+
+      if (depth === "full") {
+        // 步骤 3:L2 章节结构(附 evidence 与每章标注)
+        onProgress?.({ stageLabel: "提炼章节结构(L2)…", percentage: 55 })
+        const structureRaw = await call(
+          buildStructurePatternPrompt(sampleText, metadata.title),
+          "你是专业的小说结构分析助手。只输出用户要求的 JSON,不要解释。",
+        )
+        const structure = parseStructurePatternResult(structureRaw)
+        layers.structurePatterns = structure.structurePatterns
+        legacy.transitionStyle = structure.transitionStyle
+        legacy.descriptionWeight = structure.descriptionWeight
+        legacy.emotionRendering = structure.emotionRendering
+        legacy.thematicHabits = structure.thematicHabits
+        samples = structure.samples
+        chapterMetaAnnotations = structure.chapterMeta
+
+        const orderByChapterId = new Map(samplesRead.map((item) => [item.chapterId, item.order]))
+        evidence = parseStyleEvidenceResult(structureRaw).flatMap((candidate, index): AnalysisEvidenceSnippet[] => {
+          const chapterOrder = orderByChapterId.get(candidate.chapterId)
+          if (chapterOrder === undefined) return []
+          return [{
+            version: 1,
+            id: `evidence-${task.id}-style-${chunk.id}-${index}`,
+            bookId: task.bookId,
+            skill: "style",
+            taskId: task.id,
+            chapterId: candidate.chapterId,
+            chapterOrder,
+            text: candidate.text.trim().slice(0, 500),
+            tags: candidate.tags,
+            reason: candidate.reason || "体现作品稳定的文风机制",
+            purpose: candidate.purpose || "文风仿写参考",
+            enabled: true,
+            userPinned: false,
+            createdAt: dependencies.now(),
+            updatedAt: dependencies.now(),
+          }]
+        })
+
+        // 步骤 4:L3-L5 认知框架
+        onProgress?.({ stageLabel: "归纳认知框架(L3-L5)…", percentage: 80 })
+        const cognitiveRaw = await call(
+          buildCognitiveFramePrompt(sampleText, metadata.title),
+          "你是专业的小说叙事分析助手。只输出用户要求的 JSON,不要解释。",
+        )
+        const cognitive = parseCognitiveFrameResult(cognitiveRaw)
+        layers.cognitiveFrame = cognitive.cognitiveFrame
+        legacy.narrativeVoice = cognitive.narrativeVoice
+        legacy.pointOfView = cognitive.pointOfView
+        legacy.humorMechanisms = cognitive.humorMechanisms
+        legacy.highEnergyMechanisms = cognitive.highEnergyMechanisms
+      }
+
+      // fast 模式没有 L2,样本与证据改由脚本从原文截取——比留空可用,且不多花调用
+      if (samples.length === 0) {
+        const picked = samplesRead.slice(0, FAST_SAMPLE_COUNT)
+        samples = picked
+          .map((item) => excerptChapterBody(item.body, FAST_SAMPLE_CHARS))
+          .filter(Boolean)
+        if (evidence.length === 0) {
+          evidence = picked.flatMap((item, index): AnalysisEvidenceSnippet[] => {
+            const text = excerptChapterBody(item.body, FAST_SAMPLE_CHARS)
+            if (!text) return []
+            return [{
+              version: 1,
+              id: `evidence-${task.id}-style-${chunk.id}-auto-${index}`,
+              bookId: task.bookId,
+              skill: "style",
+              taskId: task.id,
+              chapterId: item.chapterId,
+              chapterOrder: item.order,
+              text: text.slice(0, 500),
+              tags: ["脚本抽取"],
+              reason: "快速模式下由脚本从章节开头截取的代表片段",
+              purpose: "文风仿写参考",
+              enabled: true,
+              userPinned: false,
+              createdAt: dependencies.now(),
+              updatedAt: dependencies.now(),
+            }]
+          })
+        }
+      }
+
+      const annotationById = new Map(chapterMetaAnnotations.map((item) => [item.chapterId, item]))
+      const chapterMeta = samplesRead.map((item) => buildChapterMetaEntry({
+        chapterId: item.chapterId,
+        order: item.order,
+        title: item.title,
+        body: item.body,
+        annotation: annotationById.get(item.chapterId),
+        now: dependencies.now(),
+      }))
+
       onProgress?.({ stageLabel: "文风区块分析完成", percentage: 95 })
-      return { result: { raw, profile }, evidence }
+      return {
+        result: {
+          metrics,
+          layers,
+          samples,
+          chapterMeta,
+          sampledChapterIds: samplesRead.map((item) => item.chapterId),
+          legacy,
+        },
+        evidence,
+      }
     },
-    async aggregate({ chunks, llmConfig, signal, onProgress }) {
+
+    async aggregate({ bookPath, llmConfig, chunks, signal, onProgress }) {
       if (chunks.length === 0) throw new Error("没有已完成的文风区块可供汇总")
-      const evidenceIds: string[] = []
-      if (chunks.length === 1) {
-        onProgress?.({ stageLabel: "单区块无需汇总", percentage: 95 })
-        return mergeStyleChunkProfiles(chunks, evidenceIds, dependencies.now())
-      }
-      onProgress?.({ stageLabel: "正在调用模型汇总文风…", percentage: 93 })
-      const raw = await dependencies.callModel([
-        { role: "system", content: "你只汇总已有文风分析,不分析范围外内容。" },
-        { role: "user", content: aggregatePrompt(chunks) },
+      const metadata = await dependencies.loadMetadata(bookPath)
+      const bookTitle = metadata?.title || "未命名作品"
+
+      const call = (prompt: string, system: string): Promise<string> => dependencies.callModel([
+        { role: "system", content: system },
+        { role: "user", content: prompt },
       ], llmConfig, signal)
-      const profile = parseStyleProfileResult(raw, chunks.flatMap((chunk) => chunk.profile.sampledChapterIds))
-      profile.generatedAt = dependencies.now()
-      profile.evidenceIds = evidenceIds
+
+      // 统计按原始计数相加后重新派生——比率的平均是错的
+      onProgress?.({ stageLabel: "合并统计指标…", percentage: 88 })
+      const metrics = mergeStyleMetrics(chunks.map((chunk) => chunk.metrics))
+
+      onProgress?.({ stageLabel: "合并分层产物…", percentage: 90 })
+      const layers = emptyWritingDnaLayers()
+      for (const layer of WRITING_DNA_LAYERS) {
+        layers[layer.key] = await aggregateLayer(
+          `${layer.level} ${layer.label}`,
+          chunks.map((chunk) => chunk.layers[layer.key]),
+          bookTitle,
+          (prompt) => call(prompt, "你只汇总已有分析结果,不分析范围外内容。"),
+        )
+      }
+
+      const legacyKeys = Object.keys(emptyLegacy()) as Array<keyof StyleAnalysisChunkResult["legacy"]>
+      const legacy = emptyLegacy()
+      for (const key of legacyKeys) {
+        const values = chunks.map((chunk) => chunk.legacy[key])
+        if (Array.isArray(legacy[key])) {
+          ;(legacy[key] as string[]) = unique(values.flatMap((value) => (Array.isArray(value) ? value : [])))
+        } else {
+          ;(legacy[key] as string) = mergeStrings(values.map((value) => (typeof value === "string" ? value : "")))
+        }
+      }
+
+      // 整合:产出 Writing-DNA.md 正文 + 可注入的风格硬约束
+      onProgress?.({ stageLabel: "整合 Writing DNA…", percentage: 93 })
+      const integratedRaw = await call(
+        buildDnaIntegrationPrompt(layers, metrics, bookTitle),
+        "你是文风蒸馏专家。直接输出 markdown 正文,不要 JSON,不要代码围栏。",
+      )
+      const integrated = parseIntegratedDnaResult(integratedRaw)
+
+      const chapterMeta = [...new Map(
+        chunks.flatMap((chunk) => chunk.chapterMeta).map((item) => [item.chapterId, item]),
+      ).values()].sort((a, b) => a.order - b.order)
+
+      const profile: BookStyleProfile = {
+        schemaVersion: 2,
+        generatedAt: dependencies.now(),
+        sampledChapterIds: unique(chunks.flatMap((chunk) => chunk.sampledChapterIds)),
+        metrics,
+        layers,
+        integratedDna: integrated.integratedDna,
+        constitution: integrated.constitution || FALLBACK_STYLE_CONSTITUTION,
+        samples: unique(chunks.flatMap((chunk) => chunk.samples)).slice(0, 6),
+        ...legacy,
+      }
+
       onProgress?.({ stageLabel: "文风汇总完成", percentage: 95 })
-      return profile
+      return { profile, chapterMeta }
     },
+
     async publish({ task, bookPath, projectPath, llmConfig, result, evidence, onProgress }) {
       onProgress?.({ stageLabel: "正在发布文风结果…", percentage: 97 })
       const metadata = await dependencies.loadMetadata(bookPath)
       if (!metadata) throw new Error("未找到作品元数据,无法发布文风分析")
-      result.evidenceIds = evidence.map((item) => item.id)
+
+      const { profile, chapterMeta } = result
+      profile.evidenceIds = evidence.map((item) => item.id)
+
       const resultPath = normalizePath(joinPath(bookPath, "style-profile.json"))
-      await dependencies.writeFile(resultPath, JSON.stringify(result, null, 2))
-      await dependencies.writeFile(joinPath(bookPath, "style.md"), styleProfileToMarkdown(result, metadata.title))
+      await dependencies.writeFile(resultPath, JSON.stringify(profile, null, 2))
+
+      // 分层产物 + 整合文档;style.md 保留是因为 library-state 的 styleStatus 判定依赖它
+      for (const file of renderWritingDnaFiles(profile, metadata.title, chapterMeta)) {
+        await dependencies.writeFile(joinPath(bookPath, file.fileName), file.content)
+      }
+      await dependencies.writeFile(joinPath(bookPath, "style.md"), styleProfileToMarkdown(profile, metadata.title))
+
+      if (chapterMeta.length > 0) {
+        await dependencies.saveChapterMeta(bookPath, chapterMeta)
+      }
       await dependencies.replaceEvidence(bookPath, "style", evidence)
       await dependencies.upsertPreset(projectPath, {
         name: `${metadata.title} · 文风`,
         sourceBook: metadata.title,
         sourceBookId: task.bookId,
-        evidenceIds: result.evidenceIds,
-        profile: result,
+        evidenceIds: profile.evidenceIds,
+        profile,
       })
 
       const updatedAt = dependencies.now()
@@ -212,7 +466,9 @@ export function createStyleAnalysisAdapter(
             ...task.modules.style,
             status: "completed",
             resultPath,
-            summary: `提取文风机制与 ${evidence.length} 条代表片段,覆盖第 ${task.modules.style.range.startOrder}~${task.modules.style.range.endOrder} 章。`,
+            summary: `蒸馏 Writing DNA(${INTEGRATED_DNA_FILE_NAME})与 ${evidence.length} 条代表片段,`
+              + `覆盖第 ${task.modules.style.range.startOrder}~${task.modules.style.range.endOrder} 章,`
+              + `深度 ${(task.styleDepth ?? dependencies.fallbackDepth()) === "fast" ? "快速" : "完整"}。`,
             updatedAt,
           },
         },

+ 0 - 153
src/lib/novel/book-analysis/style-extraction-engine.ts

@@ -1,153 +0,0 @@
-/**
- * 作品级写作文风提取引擎(feature/book-style-extraction)
- *
- * 一次性成本:在拆书时对抽样章节跑一次 LLM,产出 BookStyleProfile,
- * 落盘到 <bookPath>/style-profile.json + style.md。
- * 生成端只消费蒸馏后的 constitution + samples(见 writing-style-store),
- * 绝不在生成时加载整本原文。
- */
-import type { LlmConfig } from "@/stores/wiki-store"
-import { readFile, writeFile } from "@/commands/fs"
-import { joinPath } from "@/lib/path-utils"
-import { streamChat, type ChatMessage } from "@/lib/llm-client"
-import type { BookStyleProfile } from "./types"
-import { loadChapterList, loadMetadata } from "./analysis-engine"
-import {
-  STYLE_DIMENSIONS,
-  buildStyleExtractionPrompt,
-  parseStyleProfileResult,
-} from "./style-prompts"
-
-const MAX_SAMPLE_CHAPTERS = 8
-const PER_CHAPTER_CHAR_LIMIT = 6000
-/** 跳过过短的纯过渡章,避免样本不具代表性。 */
-const MIN_SAMPLE_WORD_COUNT = 800
-
-interface AnalyzeWritingStyleOptions {
-  onProgress?: (message: string) => void
-  signal?: AbortSignal
-  /** 新分析入口必须传入用户明确选择的章节;未传时仅保留旧入口兼容抽样。 */
-  selectedChapterIds?: string[]
-}
-
-function stripFrontmatter(content: string): string {
-  return content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim()
-}
-
-/** 在候选下标里均匀取最多 count 个(首/中/尾铺开)。 */
-function pickEvenlySpread<T>(items: T[], count: number): T[] {
-  if (items.length <= count) return items
-  const picked: T[] = []
-  for (let i = 0; i < count; i += 1) {
-    const idx = Math.round((i * (items.length - 1)) / (count - 1))
-    picked.push(items[idx])
-  }
-  // 去重(极端情况下 round 可能撞下标)
-  return Array.from(new Set(picked))
-}
-
-/**
- * 提取一本拆书作品的写作文风。
- * @param bookPath  <projectPath>/book-analysis/<bookId>
- */
-export async function analyzeWritingStyle(
-  bookPath: string,
-  llmConfig: LlmConfig,
-  options: AnalyzeWritingStyleOptions = {},
-): Promise<BookStyleProfile> {
-  const { onProgress, signal, selectedChapterIds } = options
-  if (signal?.aborted) throw new Error("用户取消提取")
-
-  onProgress?.("读取章节列表…")
-  const chapters = await loadChapterList(bookPath)
-  if (chapters.length === 0) throw new Error("没有可用于分析的章节,请先完成拆书章节拆分")
-
-  const metadata = await loadMetadata(bookPath)
-  const bookTitle = metadata?.title || "未命名作品"
-
-  // 优先取有一定篇幅的章节,再在其中首/中/尾均匀抽样
-  const selected = selectedChapterIds?.length
-    ? chapters.filter((chapter) => selectedChapterIds.includes(chapter.chapterId))
-    : null
-  if (selectedChapterIds?.length && selected?.length !== selectedChapterIds.length) {
-    throw new Error("所选文风章节读取不完整,请检查章节后重试")
-  }
-  const meaningful = chapters.filter((c) => c.wordCount >= MIN_SAMPLE_WORD_COUNT)
-  const pool = meaningful.length >= 3 ? meaningful : chapters
-  const sampled = selected ?? pickEvenlySpread(pool, MAX_SAMPLE_CHAPTERS)
-
-  onProgress?.(`读取 ${sampled.length} 章样本正文…`)
-  const sampleBlocks: string[] = []
-  for (const chapter of sampled) {
-    if (signal?.aborted) throw new Error("用户取消提取")
-    try {
-      const raw = await readFile(joinPath(bookPath, "chapters", `${chapter.chapterId}.md`))
-      const body = stripFrontmatter(raw).slice(0, PER_CHAPTER_CHAR_LIMIT)
-      if (body) sampleBlocks.push(`【章节ID:${chapter.chapterId} · ${chapter.title}】\n${body}`)
-    } catch {
-      // 跳过读不到的章节
-    }
-  }
-  if (sampleBlocks.length === 0) throw new Error("样本章节正文为空,无法分析文风")
-
-  onProgress?.("正在分析作品文风…")
-  const messages: ChatMessage[] = [
-    {
-      role: "system",
-      content: "你是专业的小说文风分析助手。只输出用户要求的 JSON,不要解释,不要代码围栏。",
-    },
-    {
-      role: "user",
-      content: buildStyleExtractionPrompt(sampleBlocks.join("\n\n———\n\n"), bookTitle),
-    },
-  ]
-
-  let result = ""
-  let streamError: Error | null = null
-  await streamChat(
-    llmConfig,
-    messages,
-    {
-      onToken: (token) => { result += token },
-      onDone: () => {},
-      onError: (err) => { streamError = err },
-    },
-    signal,
-    { reasoning: llmConfig.reasoning },
-  )
-  if (signal?.aborted) throw new Error("用户取消提取")
-  if (streamError) throw streamError
-
-  const profile = parseStyleProfileResult(result, sampled.map((c) => c.chapterId))
-  profile.generatedAt = Date.now()
-
-  onProgress?.("保存文风画像…")
-  await writeFile(joinPath(bookPath, "style-profile.json"), JSON.stringify(profile, null, 2))
-  await writeFile(joinPath(bookPath, "style.md"), styleProfileToMarkdown(profile, bookTitle))
-
-  return profile
-}
-
-/** 把 profile 渲染成人类可读的 markdown(下一期编辑器会用到)。 */
-export function styleProfileToMarkdown(profile: BookStyleProfile, bookTitle: string): string {
-  const lines: string[] = [
-    `# 《${bookTitle}》作品文风画像`,
-    "",
-    `> 由拆书文风提取生成 · ${profile.sampledChapterIds.length} 章样本`,
-    "",
-    "## 风格维度",
-    "",
-  ]
-  for (const dim of STYLE_DIMENSIONS) {
-    const value = (profile[dim.key] as string) || "(未提取)"
-    lines.push(`- **${dim.label}**:${value}`)
-  }
-  lines.push("", "## 风格宪法(注入生成)", "", profile.constitution, "")
-  lines.push("## 代表原文样本", "")
-  if (profile.samples.length === 0) {
-    lines.push("(无)")
-  } else {
-    profile.samples.forEach((sample, i) => lines.push(`${i + 1}. ${sample}`, ""))
-  }
-  return lines.join("\n")
-}

+ 210 - 0
src/lib/novel/book-analysis/style-metrics.spec.ts

@@ -0,0 +1,210 @@
+import { afterEach, describe, expect, it, vi } from "vitest"
+import {
+  computeStyleMetrics,
+  deriveStyleMetrics,
+  formatStyleMetricsForMarkdown,
+  formatStyleMetricsForPrompt,
+  mergeStyleMetrics,
+  resetSegmenterCache,
+  splitSentences,
+} from "./style-metrics"
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  resetSegmenterCache()
+})
+
+describe("splitSentences", () => {
+  it("splits on Chinese terminal punctuation and keeps the punctuation", () => {
+    expect(splitSentences("他走了。她没动!真的吗?")).toEqual(["他走了。", "她没动!", "真的吗?"])
+  })
+
+  it("treats an ellipsis as a single terminator", () => {
+    expect(splitSentences("他想说什么……最后什么也没说。")).toEqual(["他想说什么……", "最后什么也没说。"])
+  })
+
+  it("keeps a trailing closing quote with the sentence", () => {
+    expect(splitSentences("“别过来。”他退了一步。")).toEqual(["“别过来。”", "他退了一步。"])
+  })
+
+  it("returns a single sentence when there is no terminator", () => {
+    expect(splitSentences("没有句末标点")).toEqual(["没有句末标点"])
+  })
+})
+
+describe("computeStyleMetrics", () => {
+  it("counts sentences, paragraphs and the short sentence ratio", () => {
+    const metrics = computeStyleMetrics(["他走了。\n\n她没动。"])
+    expect(metrics.counts.chapters).toBe(1)
+    expect(metrics.counts.sentences).toBe(2)
+    expect(metrics.counts.paragraphs).toBe(2)
+    expect(metrics.counts.oneSentenceParagraphs).toBe(2)
+    expect(metrics.derived.shortSentenceRatio).toBe(1)
+    expect(metrics.derived.longSentenceRatio).toBe(0)
+    expect(metrics.derived.oneSentenceParagraphRatio).toBe(1)
+  })
+
+  it("flags long sentences and long paragraphs", () => {
+    const longSentence = `${"这是一个很长的句子用来测试长句判定".repeat(4)}。`
+    const metrics = computeStyleMetrics([longSentence])
+    expect(metrics.counts.longSentences).toBe(1)
+    expect(metrics.counts.longParagraphs).toBe(0)
+    expect(metrics.derived.longSentenceRatio).toBe(1)
+  })
+
+  it("detects dialogue paragraphs by quote marks", () => {
+    const metrics = computeStyleMetrics(["“你来了。”\n他点头。"])
+    expect(metrics.counts.dialogueParagraphs).toBe(1)
+    expect(metrics.derived.dialogueParagraphRatio).toBe(0.5)
+  })
+
+  it("counts punctuation per thousand characters", () => {
+    const metrics = computeStyleMetrics(["他——真的走了!你信吗?"])
+    expect(metrics.counts.punctuation.dash).toBe(1)
+    expect(metrics.counts.punctuation.exclamation).toBe(1)
+    expect(metrics.counts.punctuation.question).toBe(1)
+    expect(metrics.derived.punctuationPerThousand.dash).toBeGreaterThan(0)
+  })
+
+  it("measures the latin character ratio for mixed text", () => {
+    const metrics = computeStyleMetrics(["他打开了 API 文档。"])
+    expect(metrics.counts.latinChars).toBe(3)
+    expect(metrics.derived.latinCharRatio).toBeGreaterThan(0)
+  })
+
+  it("filters single characters and stop words out of topWords", () => {
+    const metrics = computeStyleMetrics(["他没有回头。他没有回头。他没有回头。"])
+    expect(metrics.topWords.every((item) => item.word.length >= 2)).toBe(true)
+    expect(metrics.topWords.some((item) => item.word === "没有")).toBe(false)
+    expect(metrics.topWords.some((item) => item.word === "回头")).toBe(true)
+  })
+
+  it("collects sentence openers", () => {
+    const metrics = computeStyleMetrics(["然后他走了。然后她走了。"])
+    expect(metrics.topSentenceOpeners[0]).toEqual({ word: "然后", count: 2 })
+  })
+
+  it("returns all-zero metrics for empty input without producing NaN", () => {
+    const metrics = computeStyleMetrics([])
+    expect(metrics.counts.chapters).toBe(0)
+    expect(metrics.derived.avgSentenceChars).toBe(0)
+    expect(metrics.derived.shortSentenceRatio).toBe(0)
+    expect(metrics.derived.punctuationPerThousand.comma).toBe(0)
+    expect(metrics.segmenterAvailable).toBe(false)
+  })
+
+  it("skips blank chapter bodies", () => {
+    const metrics = computeStyleMetrics(["", "   ", "他走了。"])
+    expect(metrics.counts.chapters).toBe(1)
+  })
+
+  it("handles pure english text", () => {
+    const metrics = computeStyleMetrics(["He left. She stayed."])
+    expect(metrics.counts.chars).toBeGreaterThan(0)
+    expect(metrics.derived.latinCharRatio).toBeGreaterThan(0.5)
+  })
+
+  it("falls back to character bigrams when Intl.Segmenter is unavailable", () => {
+    vi.stubGlobal("Intl", { ...Intl, Segmenter: undefined })
+    resetSegmenterCache()
+    const metrics = computeStyleMetrics(["他没有回头,只是把烟按在栏杆上。"])
+    expect(metrics.segmenterAvailable).toBe(false)
+    expect(metrics.topWords.length).toBeGreaterThan(0)
+    expect(metrics.counts.sentences).toBe(1)
+  })
+
+  it("falls back when Segmenter cannot segment Chinese words", () => {
+    class NoopSegmenter {
+      segment(input: string) {
+        return [{ segment: input, index: 0, input, isWordLike: true }]
+      }
+    }
+    vi.stubGlobal("Intl", { ...Intl, Segmenter: NoopSegmenter })
+    resetSegmenterCache()
+    const metrics = computeStyleMetrics(["他没有回头,只是把烟按在栏杆上。"])
+    expect(metrics.segmenterAvailable).toBe(false)
+  })
+})
+
+describe("mergeStyleMetrics", () => {
+  it("sums raw counts and re-derives ratios instead of averaging them", () => {
+    const first = computeStyleMetrics(["他走了。"])
+    const second = computeStyleMetrics([`${"这是一个很长的句子用来测试长句判定".repeat(4)}。`])
+    const merged = mergeStyleMetrics([first, second])
+
+    expect(merged.counts.chapters).toBe(2)
+    expect(merged.counts.sentences).toBe(2)
+    expect(merged.derived.shortSentenceRatio).toBe(0.5)
+    expect(merged.derived.longSentenceRatio).toBe(0.5)
+    expect(merged.derived.avgSentenceChars).toBe(
+      Number(((first.counts.sentenceCharTotal + second.counts.sentenceCharTotal) / 2).toFixed(2)),
+    )
+  })
+
+  it("merges word frequency tables by summing counts", () => {
+    const merged = mergeStyleMetrics([
+      computeStyleMetrics(["他没有回头。"]),
+      computeStyleMetrics(["他没有回头。"]),
+    ])
+    expect(merged.topWords.find((item) => item.word === "回头")?.count).toBe(2)
+  })
+
+  it("returns the single item unchanged", () => {
+    const only = computeStyleMetrics(["他走了。"])
+    expect(mergeStyleMetrics([only])).toBe(only)
+  })
+
+  it("returns empty metrics for an empty list", () => {
+    expect(mergeStyleMetrics([]).counts.chapters).toBe(0)
+  })
+
+  it("marks the segmenter unavailable when any chunk lacked it", () => {
+    const good = computeStyleMetrics(["他没有回头。"])
+    const degraded = { ...good, segmenterAvailable: false }
+    expect(mergeStyleMetrics([good, degraded]).segmenterAvailable).toBe(false)
+  })
+})
+
+describe("deriveStyleMetrics", () => {
+  it("never divides by zero", () => {
+    const derived = deriveStyleMetrics({
+      chapters: 0,
+      chars: 0,
+      latinChars: 0,
+      sentences: 0,
+      shortSentences: 0,
+      longSentences: 0,
+      sentenceCharTotal: 0,
+      paragraphs: 0,
+      oneSentenceParagraphs: 0,
+      longParagraphs: 0,
+      dialogueParagraphs: 0,
+      punctuation: {
+        dash: 0, ellipsis: 0, quote: 0, exclamation: 0,
+        question: 0, comma: 0, semicolon: 0, colon: 0,
+      },
+    })
+    for (const value of Object.values(derived.punctuationPerThousand)) expect(value).toBe(0)
+    expect(Number.isNaN(derived.avgParagraphChars)).toBe(false)
+  })
+})
+
+describe("formatting", () => {
+  it("renders numbers for the prompt without drawing conclusions", () => {
+    const text = formatStyleMetricsForPrompt(computeStyleMetrics(["他走了。她没动。"]))
+    expect(text).toContain("平均句长")
+    expect(text).toContain("短句")
+    expect(text).toContain("高频实词")
+  })
+
+  it("warns in the prompt when the segmenter was unavailable", () => {
+    const metrics = { ...computeStyleMetrics(["他走了。"]), segmenterAvailable: false }
+    expect(formatStyleMetricsForPrompt(metrics)).toContain("缺少中文分词能力")
+  })
+
+  it("renders a markdown table", () => {
+    const markdown = formatStyleMetricsForMarkdown(computeStyleMetrics(["他走了。"]))
+    expect(markdown).toContain("| 指标 | 数值 |")
+    expect(markdown).toContain("平均句长")
+  })
+})

+ 438 - 0
src/lib/novel/book-analysis/style-metrics.ts

@@ -0,0 +1,438 @@
+/**
+ * L1 语言 / L6 排版节奏的确定性统计(feature/writing-dna)
+ *
+ * 纯函数、零依赖、不调 LLM。分层蒸馏里 L1 和 L6 的数字全部由这里算出,
+ * 再把数字喂进 prompt 让模型解读,避免让模型凭感觉猜"句子偏短""节奏偏快"。
+ *
+ * 中文分词用运行时自带的 Intl.Segmenter;不可用时降级为相邻汉字二元窗口,
+ * 并把 segmenterAvailable 置为 false,供 UI 与 prompt 标注可信度。
+ */
+
+/** 原始计数:多分片合并时直接相加,再重新派生比率,避免"比率的平均"失真。 */
+export interface StyleMetricCounts {
+  chapters: number
+  chars: number
+  latinChars: number
+  sentences: number
+  shortSentences: number
+  longSentences: number
+  sentenceCharTotal: number
+  paragraphs: number
+  oneSentenceParagraphs: number
+  longParagraphs: number
+  dialogueParagraphs: number
+  punctuation: {
+    dash: number
+    ellipsis: number
+    quote: number
+    exclamation: number
+    question: number
+    comma: number
+    semicolon: number
+    colon: number
+  }
+}
+
+export interface WordFrequency {
+  word: string
+  count: number
+}
+
+/** 派生指标:全部可由 counts 重算,落盘只为可读性与 prompt 直接引用。 */
+export interface StyleMetricDerived {
+  /** 平均句长(字符) */
+  avgSentenceChars: number
+  /** 短句占比:不超过 SHORT_SENTENCE_MAX_CHARS 字 */
+  shortSentenceRatio: number
+  /** 长句占比:不少于 LONG_SENTENCE_MIN_CHARS 字 */
+  longSentenceRatio: number
+  /** 平均段落字数 */
+  avgParagraphChars: number
+  /** 平均每段句数 */
+  avgParagraphSentences: number
+  /** 一句一段占比 */
+  oneSentenceParagraphRatio: number
+  /** 长段(不少于 LONG_PARAGRAPH_MIN_CHARS 字)占比 */
+  longParagraphRatio: number
+  /** 含引号对白的段落占比 */
+  dialogueParagraphRatio: number
+  /** 各标点每千字频次 */
+  punctuationPerThousand: Record<keyof StyleMetricCounts["punctuation"], number>
+  /** 拉丁字符占比(中英混用程度) */
+  latinCharRatio: number
+}
+
+export interface StyleMetrics {
+  schemaVersion: 1
+  segmenterAvailable: boolean
+  counts: StyleMetricCounts
+  derived: StyleMetricDerived
+  /** 高频实词(已过滤单字与功能词) */
+  topWords: WordFrequency[]
+  /** 高频二元搭配 */
+  topBigrams: WordFrequency[]
+  /** 高频句首词 */
+  topSentenceOpeners: WordFrequency[]
+}
+
+export const SHORT_SENTENCE_MAX_CHARS = 15
+export const LONG_SENTENCE_MIN_CHARS = 50
+export const LONG_PARAGRAPH_MIN_CHARS = 200
+
+const TOP_WORD_LIMIT = 40
+const TOP_BIGRAM_LIMIT = 25
+const TOP_OPENER_LIMIT = 20
+
+/** 中文功能词:高频但不体现文风,从 topWords 里剔除。 */
+const STOP_WORDS = new Set([
+  "自己", "什么", "怎么", "这样", "那样", "已经", "可以", "可能", "但是", "因为", "所以",
+  "如果", "虽然", "然后", "现在", "时候", "起来", "出来", "过去", "一个", "一些", "没有",
+  "就是", "还是", "这个", "那个", "我们", "你们", "他们", "她们", "于是", "不过", "只是",
+  "而且", "以后", "以前", "一下", "一样", "这些", "那些", "而是", "不是", "有些", "非常",
+  "十分", "particularly", "however",
+])
+
+function isCjk(char: string): boolean {
+  const code = char.codePointAt(0) ?? 0
+  return code >= 0x4e00 && code <= 0x9fff
+}
+
+function isLatinLetter(char: string): boolean {
+  return /[A-Za-z]/.test(char)
+}
+
+function emptyCounts(): StyleMetricCounts {
+  return {
+    chapters: 0,
+    chars: 0,
+    latinChars: 0,
+    sentences: 0,
+    shortSentences: 0,
+    longSentences: 0,
+    sentenceCharTotal: 0,
+    paragraphs: 0,
+    oneSentenceParagraphs: 0,
+    longParagraphs: 0,
+    dialogueParagraphs: 0,
+    punctuation: {
+      dash: 0,
+      ellipsis: 0,
+      quote: 0,
+      exclamation: 0,
+      question: 0,
+      comma: 0,
+      semicolon: 0,
+      colon: 0,
+    },
+  }
+}
+
+/** 按中文句末标点切句;省略号与连续标点算一个句末。 */
+export function splitSentences(text: string): string[] {
+  return text
+    .replace(/([。!?…]+["'”』」))]?)/g, "$1\u0000")
+    .split("\u0000")
+    .map((item) => item.trim())
+    .filter(Boolean)
+}
+
+function splitParagraphs(text: string): string[] {
+  return text
+    .split(/\r?\n+/)
+    .map((item) => item.trim())
+    .filter(Boolean)
+}
+
+function countMatches(text: string, pattern: RegExp): number {
+  return text.match(pattern)?.length ?? 0
+}
+
+/**
+ * Intl.Segmenter 的最小结构声明。
+ * 当前 tsconfig 的 lib 里没有它,为一个可选运行时能力去抬 lib 版本不值得。
+ */
+interface WordSegment {
+  segment: string
+  isWordLike?: boolean
+}
+
+interface WordSegmenter {
+  segment(input: string): Iterable<WordSegment>
+}
+
+type WordSegmenterCtor = new (
+  locale: string,
+  options: { granularity: "word" },
+) => WordSegmenter
+
+let segmenterCache: WordSegmenter | null | undefined
+
+/** 拿一个中文分词器;运行时不支持时返回 null(只探测一次)。 */
+function getSegmenter(): WordSegmenter | null {
+  if (segmenterCache !== undefined) return segmenterCache
+  try {
+    const SegmenterCtor = (Intl as unknown as { Segmenter?: WordSegmenterCtor }).Segmenter
+    if (!SegmenterCtor) {
+      segmenterCache = null
+      return null
+    }
+    const instance = new SegmenterCtor("zh-CN", { granularity: "word" })
+    // 探测:不支持中文粒度的实现会把整句当一个 segment
+    const probe = [...instance.segment("他没有回头,只是把烟按在栏杆上")].filter((item) => item.isWordLike)
+    segmenterCache = probe.length >= 3 ? instance : null
+  } catch {
+    segmenterCache = null
+  }
+  return segmenterCache
+}
+
+/** 仅供测试:重置分词器探测缓存。 */
+export function resetSegmenterCache(): void {
+  segmenterCache = undefined
+}
+
+/** 降级分词:相邻汉字二元窗口 + 连续拉丁词。 */
+function fallbackTokenize(text: string): string[] {
+  const tokens: string[] = []
+  const latin = text.match(/[A-Za-z][A-Za-z'-]*/g) ?? []
+  tokens.push(...latin.map((item) => item.toLowerCase()))
+  const chars = [...text].filter(isCjk)
+  for (let i = 0; i + 1 < chars.length; i += 1) {
+    tokens.push(chars[i] + chars[i + 1])
+  }
+  return tokens
+}
+
+function tokenize(text: string): { tokens: string[]; segmented: boolean } {
+  const segmenter = getSegmenter()
+  if (!segmenter) return { tokens: fallbackTokenize(text), segmented: false }
+  const tokens: string[] = []
+  for (const item of segmenter.segment(text)) {
+    if (!item.isWordLike) continue
+    const word = item.segment.trim()
+    if (word) tokens.push(isLatinLetter(word[0]) ? word.toLowerCase() : word)
+  }
+  return { tokens, segmented: true }
+}
+
+function bump(map: Map<string, number>, key: string, delta = 1): void {
+  map.set(key, (map.get(key) ?? 0) + delta)
+}
+
+function topEntries(map: Map<string, number>, limit: number): WordFrequency[] {
+  return [...map.entries()]
+    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+    .slice(0, limit)
+    .map(([word, count]) => ({ word, count }))
+}
+
+/** 由原始计数派生比率;分母为 0 时全部返回 0,不产生 NaN。 */
+export function deriveStyleMetrics(counts: StyleMetricCounts): StyleMetricDerived {
+  const ratio = (numerator: number, denominator: number): number =>
+    denominator > 0 ? Number((numerator / denominator).toFixed(4)) : 0
+  const perThousand = (value: number): number =>
+    counts.chars > 0 ? Number(((value * 1000) / counts.chars).toFixed(2)) : 0
+
+  return {
+    avgSentenceChars: counts.sentences > 0
+      ? Number((counts.sentenceCharTotal / counts.sentences).toFixed(2))
+      : 0,
+    shortSentenceRatio: ratio(counts.shortSentences, counts.sentences),
+    longSentenceRatio: ratio(counts.longSentences, counts.sentences),
+    avgParagraphChars: counts.paragraphs > 0
+      ? Number((counts.chars / counts.paragraphs).toFixed(2))
+      : 0,
+    avgParagraphSentences: counts.paragraphs > 0
+      ? Number((counts.sentences / counts.paragraphs).toFixed(2))
+      : 0,
+    oneSentenceParagraphRatio: ratio(counts.oneSentenceParagraphs, counts.paragraphs),
+    longParagraphRatio: ratio(counts.longParagraphs, counts.paragraphs),
+    dialogueParagraphRatio: ratio(counts.dialogueParagraphs, counts.paragraphs),
+    punctuationPerThousand: {
+      dash: perThousand(counts.punctuation.dash),
+      ellipsis: perThousand(counts.punctuation.ellipsis),
+      quote: perThousand(counts.punctuation.quote),
+      exclamation: perThousand(counts.punctuation.exclamation),
+      question: perThousand(counts.punctuation.question),
+      comma: perThousand(counts.punctuation.comma),
+      semicolon: perThousand(counts.punctuation.semicolon),
+      colon: perThousand(counts.punctuation.colon),
+    },
+    latinCharRatio: ratio(counts.latinChars, counts.chars),
+  }
+}
+
+/**
+ * 对一批章节正文做 L1 / L6 统计。
+ * @param chapterBodies 已剥掉 frontmatter 的章节正文
+ */
+export function computeStyleMetrics(chapterBodies: string[]): StyleMetrics {
+  const counts = emptyCounts()
+  const wordFreq = new Map<string, number>()
+  const bigramFreq = new Map<string, number>()
+  const openerFreq = new Map<string, number>()
+  let segmented = true
+
+  for (const body of chapterBodies) {
+    const text = body.trim()
+    if (!text) continue
+    counts.chapters += 1
+    counts.chars += text.length
+    for (const char of text) {
+      if (isLatinLetter(char)) counts.latinChars += 1
+    }
+
+    counts.punctuation.dash += countMatches(text, /——|—/g)
+    counts.punctuation.ellipsis += countMatches(text, /…{1,2}|\.{3,}/g)
+    counts.punctuation.quote += countMatches(text, /[“”「」『』"]/g)
+    counts.punctuation.exclamation += countMatches(text, /[!!]/g)
+    counts.punctuation.question += countMatches(text, /[??]/g)
+    counts.punctuation.comma += countMatches(text, /[,,、]/g)
+    counts.punctuation.semicolon += countMatches(text, /[;;]/g)
+    counts.punctuation.colon += countMatches(text, /[::]/g)
+
+    for (const paragraph of splitParagraphs(text)) {
+      counts.paragraphs += 1
+      if (paragraph.length >= LONG_PARAGRAPH_MIN_CHARS) counts.longParagraphs += 1
+      if (/[“”「」『』]/.test(paragraph)) counts.dialogueParagraphs += 1
+      const sentences = splitSentences(paragraph)
+      if (sentences.length <= 1) counts.oneSentenceParagraphs += 1
+      for (const sentence of sentences) {
+        counts.sentences += 1
+        counts.sentenceCharTotal += sentence.length
+        if (sentence.length <= SHORT_SENTENCE_MAX_CHARS) counts.shortSentences += 1
+        if (sentence.length >= LONG_SENTENCE_MIN_CHARS) counts.longSentences += 1
+
+        const { tokens, segmented: ok } = tokenize(sentence)
+        if (!ok) segmented = false
+        if (tokens.length > 0) bump(openerFreq, tokens[0])
+        for (let i = 0; i < tokens.length; i += 1) {
+          const token = tokens[i]
+          if (token.length >= 2 && !STOP_WORDS.has(token)) bump(wordFreq, token)
+          if (i + 1 < tokens.length) bump(bigramFreq, `${token}${tokens[i + 1]}`)
+        }
+      }
+    }
+  }
+
+  return {
+    schemaVersion: 1,
+    segmenterAvailable: segmented && counts.chapters > 0,
+    counts,
+    derived: deriveStyleMetrics(counts),
+    topWords: topEntries(wordFreq, TOP_WORD_LIMIT),
+    topBigrams: topEntries(bigramFreq, TOP_BIGRAM_LIMIT),
+    topSentenceOpeners: topEntries(openerFreq, TOP_OPENER_LIMIT),
+  }
+}
+
+/** 多分片合并:计数相加后重新派生,词频表相加后重新取 top。 */
+export function mergeStyleMetrics(items: StyleMetrics[]): StyleMetrics {
+  const present = items.filter(Boolean)
+  if (present.length === 0) return computeStyleMetrics([])
+  if (present.length === 1) return present[0]
+
+  const counts = emptyCounts()
+  const wordFreq = new Map<string, number>()
+  const bigramFreq = new Map<string, number>()
+  const openerFreq = new Map<string, number>()
+
+  for (const item of present) {
+    counts.chapters += item.counts.chapters
+    counts.chars += item.counts.chars
+    counts.latinChars += item.counts.latinChars
+    counts.sentences += item.counts.sentences
+    counts.shortSentences += item.counts.shortSentences
+    counts.longSentences += item.counts.longSentences
+    counts.sentenceCharTotal += item.counts.sentenceCharTotal
+    counts.paragraphs += item.counts.paragraphs
+    counts.oneSentenceParagraphs += item.counts.oneSentenceParagraphs
+    counts.longParagraphs += item.counts.longParagraphs
+    counts.dialogueParagraphs += item.counts.dialogueParagraphs
+    for (const key of Object.keys(counts.punctuation) as Array<keyof StyleMetricCounts["punctuation"]>) {
+      counts.punctuation[key] += item.counts.punctuation[key]
+    }
+    for (const entry of item.topWords) bump(wordFreq, entry.word, entry.count)
+    for (const entry of item.topBigrams) bump(bigramFreq, entry.word, entry.count)
+    for (const entry of item.topSentenceOpeners) bump(openerFreq, entry.word, entry.count)
+  }
+
+  return {
+    schemaVersion: 1,
+    segmenterAvailable: present.every((item) => item.segmenterAvailable),
+    counts,
+    derived: deriveStyleMetrics(counts),
+    topWords: topEntries(wordFreq, TOP_WORD_LIMIT),
+    topBigrams: topEntries(bigramFreq, TOP_BIGRAM_LIMIT),
+    topSentenceOpeners: topEntries(openerFreq, TOP_OPENER_LIMIT),
+  }
+}
+
+function formatFrequencies(items: WordFrequency[], limit: number): string {
+  const sliced = items.slice(0, limit)
+  if (sliced.length === 0) return "(无)"
+  return sliced.map((item) => `${item.word}(${item.count})`).join("、")
+}
+
+/**
+ * 把统计结果渲染成给 LLM 读的紧凑文本块。
+ * 只给数字,不给结论——结论由模型在 L1/L6 prompt 里产出。
+ */
+export function formatStyleMetricsForPrompt(metrics: StyleMetrics): string {
+  const { counts, derived } = metrics
+  const percent = (value: number): string => `${(value * 100).toFixed(1)}%`
+  return [
+    `样本规模:${counts.chapters} 章 / ${counts.chars} 字 / ${counts.sentences} 句 / ${counts.paragraphs} 段`,
+    `平均句长:${derived.avgSentenceChars} 字`,
+    `短句(≤${SHORT_SENTENCE_MAX_CHARS}字)占比:${percent(derived.shortSentenceRatio)}`,
+    `长句(≥${LONG_SENTENCE_MIN_CHARS}字)占比:${percent(derived.longSentenceRatio)}`,
+    `平均段落:${derived.avgParagraphChars} 字 / ${derived.avgParagraphSentences} 句`,
+    `一句一段占比:${percent(derived.oneSentenceParagraphRatio)}`,
+    `长段(≥${LONG_PARAGRAPH_MIN_CHARS}字)占比:${percent(derived.longParagraphRatio)}`,
+    `含引号对白段落占比:${percent(derived.dialogueParagraphRatio)}`,
+    `标点每千字频次:破折号 ${derived.punctuationPerThousand.dash}、省略号 ${derived.punctuationPerThousand.ellipsis}、`
+      + `引号 ${derived.punctuationPerThousand.quote}、感叹号 ${derived.punctuationPerThousand.exclamation}、`
+      + `问号 ${derived.punctuationPerThousand.question}、逗号 ${derived.punctuationPerThousand.comma}、`
+      + `分号 ${derived.punctuationPerThousand.semicolon}、冒号 ${derived.punctuationPerThousand.colon}`,
+    `拉丁字符占比:${percent(derived.latinCharRatio)}`,
+    `高频实词:${formatFrequencies(metrics.topWords, 30)}`,
+    `高频二元搭配:${formatFrequencies(metrics.topBigrams, 20)}`,
+    `高频句首词:${formatFrequencies(metrics.topSentenceOpeners, 15)}`,
+    metrics.segmenterAvailable
+      ? ""
+      : "注意:本次运行环境缺少中文分词能力,上面的词频表来自相邻汉字二元窗口,属近似结果,请只用作参考,不要把它当成精确词表。",
+  ].filter(Boolean).join("\n")
+}
+
+/** 把统计结果渲染成人类可读 markdown 表格(写入语言DNA.md 与排版节奏指南.md)。 */
+export function formatStyleMetricsForMarkdown(metrics: StyleMetrics): string {
+  const { counts, derived } = metrics
+  const percent = (value: number): string => `${(value * 100).toFixed(1)}%`
+  const rows: Array<[string, string]> = [
+    ["样本章数", String(counts.chapters)],
+    ["样本字数", String(counts.chars)],
+    ["平均句长", `${derived.avgSentenceChars} 字`],
+    [`短句占比(≤${SHORT_SENTENCE_MAX_CHARS}字)`, percent(derived.shortSentenceRatio)],
+    [`长句占比(≥${LONG_SENTENCE_MIN_CHARS}字)`, percent(derived.longSentenceRatio)],
+    ["平均段落字数", `${derived.avgParagraphChars} 字`],
+    ["平均每段句数", `${derived.avgParagraphSentences} 句`],
+    ["一句一段占比", percent(derived.oneSentenceParagraphRatio)],
+    [`长段占比(≥${LONG_PARAGRAPH_MIN_CHARS}字)`, percent(derived.longParagraphRatio)],
+    ["含对白段落占比", percent(derived.dialogueParagraphRatio)],
+    ["破折号(每千字)", String(derived.punctuationPerThousand.dash)],
+    ["省略号(每千字)", String(derived.punctuationPerThousand.ellipsis)],
+    ["感叹号(每千字)", String(derived.punctuationPerThousand.exclamation)],
+    ["问号(每千字)", String(derived.punctuationPerThousand.question)],
+    ["逗号(每千字)", String(derived.punctuationPerThousand.comma)],
+    ["分号(每千字)", String(derived.punctuationPerThousand.semicolon)],
+    ["拉丁字符占比", percent(derived.latinCharRatio)],
+  ]
+  const lines = ["| 指标 | 数值 |", "| - | - |", ...rows.map(([key, value]) => `| ${key} | ${value} |`)]
+  lines.push("", `**高频实词**:${formatFrequencies(metrics.topWords, 30)}`)
+  lines.push("", `**高频二元搭配**:${formatFrequencies(metrics.topBigrams, 20)}`)
+  lines.push("", `**高频句首词**:${formatFrequencies(metrics.topSentenceOpeners, 15)}`)
+  if (!metrics.segmenterAvailable) {
+    lines.push("", "> 本次运行环境缺少中文分词能力,词频表为相邻汉字二元窗口的近似结果。")
+  }
+  return lines.join("\n")
+}

+ 175 - 0
src/lib/novel/book-analysis/style-profile-schema.spec.ts

@@ -0,0 +1,175 @@
+import { describe, expect, it } from "vitest"
+import {
+  INTEGRATED_DNA_CHAR_LIMIT,
+  WRITING_DNA_LAYERS,
+  emptyWritingDnaLayers,
+  isWritingDnaProfile,
+  migrateStyleProfile,
+  needsReextraction,
+} from "./style-profile-schema"
+import { computeStyleMetrics } from "./style-metrics"
+import type { BookStyleProfile } from "./types"
+
+function v1Profile(overrides: Partial<BookStyleProfile> = {}): BookStyleProfile {
+  return {
+    schemaVersion: 1,
+    generatedAt: 1,
+    sampledChapterIds: ["ch-0001"],
+    narrativeDensity: "推进快",
+    sentenceStyle: "短句为主",
+    rhetoricDensity: "比喻少",
+    dialogueStyle: "口语毛边",
+    transitionStyle: "直接跳时间",
+    descriptionWeight: "描写克制",
+    emotionRendering: "动作外显",
+    thematicHabits: "不点题",
+    narrativeVoice: "冷静叙述者",
+    pointOfView: "第三人称限知",
+    vocabularyPreferences: ["回头", "愣住"],
+    avoidPatterns: ["长比喻链"],
+    humorMechanisms: ["补刀"],
+    highEnergyMechanisms: ["抬压"],
+    constitution: "1. 朴素\n2. 克制",
+    samples: ["片段一"],
+    ...overrides,
+  }
+}
+
+describe("WRITING_DNA_LAYERS", () => {
+  it("四层齐全且文件名唯一", () => {
+    expect(WRITING_DNA_LAYERS).toHaveLength(4)
+    const keys = WRITING_DNA_LAYERS.map((item) => item.key)
+    expect(new Set(keys).size).toBe(4)
+    expect(new Set(WRITING_DNA_LAYERS.map((item) => item.fileName)).size).toBe(4)
+    expect(keys).toEqual(Object.keys(emptyWritingDnaLayers()))
+  })
+
+  it("整合文档上限沿用 writing-dna-skill 的 4000 字标准", () => {
+    expect(INTEGRATED_DNA_CHAR_LIMIT).toBe(4000)
+  })
+})
+
+describe("migrateStyleProfile", () => {
+  it("把 v1 的 9 个维度按层归类,不丢内容", () => {
+    const migrated = migrateStyleProfile(v1Profile())
+
+    expect(migrated.schemaVersion).toBe(2)
+    expect(migrated.layers?.languageDna).toContain("推进快")
+    expect(migrated.layers?.languageDna).toContain("短句为主")
+    expect(migrated.layers?.languageDna).toContain("比喻少")
+    expect(migrated.layers?.languageDna).toContain("口语毛边")
+    expect(migrated.layers?.languageDna).toContain("回头、愣住")
+    expect(migrated.layers?.structurePatterns).toContain("直接跳时间")
+    expect(migrated.layers?.structurePatterns).toContain("描写克制")
+    expect(migrated.layers?.structurePatterns).toContain("长比喻链")
+    expect(migrated.layers?.cognitiveFrame).toContain("冷静叙述者")
+    expect(migrated.layers?.cognitiveFrame).toContain("第三人称限知")
+    expect(migrated.layers?.cognitiveFrame).toContain("补刀")
+  })
+
+  it("v1 没有 L6 对应维度,rhythmGuide 留空而不是编造", () => {
+    expect(migrateStyleProfile(v1Profile()).layers?.rhythmGuide).toBe("")
+  })
+
+  it("保留 constitution、samples 与章节 id", () => {
+    const migrated = migrateStyleProfile(v1Profile())
+    expect(migrated.constitution).toBe("1. 朴素\n2. 克制")
+    expect(migrated.samples).toEqual(["片段一"])
+    expect(migrated.sampledChapterIds).toEqual(["ch-0001"])
+  })
+
+  it("v1 迁移后 integratedDna 仍为空", () => {
+    expect(migrateStyleProfile(v1Profile()).integratedDna).toBe("")
+  })
+
+  it("已有 layers 的 v2 数据不被 9 维覆盖", () => {
+    const migrated = migrateStyleProfile(v1Profile({
+      schemaVersion: 2,
+      layers: {
+        languageDna: "已有的 L1",
+        structurePatterns: "已有的 L2",
+        cognitiveFrame: "已有的 L3",
+        rhythmGuide: "已有的 L6",
+      },
+      integratedDna: "整合文档",
+    }))
+
+    expect(migrated.layers?.languageDna).toBe("已有的 L1")
+    expect(migrated.layers?.rhythmGuide).toBe("已有的 L6")
+    expect(migrated.integratedDna).toBe("整合文档")
+  })
+
+  it("部分 layers 缺字段时补齐为空串", () => {
+    const migrated = migrateStyleProfile(v1Profile({
+      layers: { languageDna: "只有 L1" } as never,
+    }))
+    expect(migrated.layers?.languageDna).toBe("只有 L1")
+    expect(migrated.layers?.structurePatterns).toBe("")
+  })
+
+  it("缺 metrics 时补一个空统计而不是 undefined", () => {
+    const migrated = migrateStyleProfile(v1Profile())
+    expect(migrated.metrics?.counts.chapters).toBe(0)
+  })
+
+  it("字段类型异常时不抛错", () => {
+    const migrated = migrateStyleProfile({
+      ...v1Profile(),
+      samples: undefined as never,
+      sampledChapterIds: undefined as never,
+      constitution: undefined as never,
+    })
+    expect(migrated.samples).toEqual([])
+    expect(migrated.sampledChapterIds).toEqual([])
+    expect(migrated.constitution).toBe("")
+  })
+})
+
+describe("isWritingDnaProfile", () => {
+  it("有整合文档即视为 v2", () => {
+    expect(isWritingDnaProfile(v1Profile({ integratedDna: "整合" }))).toBe(true)
+  })
+
+  it("任一层非空即视为 v2", () => {
+    expect(isWritingDnaProfile(v1Profile({
+      layers: { ...emptyWritingDnaLayers(), rhythmGuide: "一句一段" },
+    }))).toBe(true)
+  })
+
+  it("纯 v1 数据返回 false", () => {
+    expect(isWritingDnaProfile(v1Profile())).toBe(false)
+  })
+
+  it("layers 全空返回 false", () => {
+    expect(isWritingDnaProfile(v1Profile({ layers: emptyWritingDnaLayers() }))).toBe(false)
+  })
+
+  it("空值安全", () => {
+    expect(isWritingDnaProfile(null)).toBe(false)
+    expect(isWritingDnaProfile(undefined)).toBe(false)
+  })
+})
+
+describe("needsReextraction", () => {
+  it("v1 迁移产物需要重新提取", () => {
+    expect(needsReextraction(migrateStyleProfile(v1Profile()))).toBe(true)
+  })
+
+  it("有分层但统计为空仍需重新提取", () => {
+    expect(needsReextraction(v1Profile({
+      integratedDna: "整合",
+      metrics: computeStyleMetrics([]),
+    }))).toBe(true)
+  })
+
+  it("分层与统计齐全时不需要重新提取", () => {
+    expect(needsReextraction(v1Profile({
+      integratedDna: "整合",
+      metrics: computeStyleMetrics(["他走了。"]),
+    }))).toBe(false)
+  })
+
+  it("没有画像时返回 false(而不是催用户重提取一个不存在的东西)", () => {
+    expect(needsReextraction(null)).toBe(false)
+  })
+})

+ 148 - 0
src/lib/novel/book-analysis/style-profile-schema.ts

@@ -0,0 +1,148 @@
+/**
+ * BookStyleProfile v1 → v2 迁移与分层产物元数据(feature/writing-dna)
+ *
+ * v1 是单轮 LLM 出的 9 个主观维度;v2 是 writing-dna 的分层蒸馏(L1-L6 + 整合文档)。
+ * 磁盘上和 .qmai/writing-style.json 里都可能存着 v1 数据,所有读取入口都要先过 migrate。
+ * 迁移只做无损搬运:把 v1 的 9 个维度按层归类拼成 layers,绝不凭空造内容。
+ */
+import { computeStyleMetrics } from "./style-metrics"
+import type { BookStyleProfile, WritingDnaLayers } from "./types"
+
+/** 分层产物的层次元数据:key 对应 WritingDnaLayers 字段,fileName 是落盘文件名。 */
+export const WRITING_DNA_LAYERS = [
+  {
+    key: "languageDna",
+    level: "L1",
+    label: "语言 DNA",
+    fileName: "语言DNA.md",
+    summary: "句长分布、标点习惯、高频词与二元搭配、对白口语度",
+  },
+  {
+    key: "structurePatterns",
+    level: "L2",
+    label: "章节结构模板",
+    fileName: "章节结构模板.md",
+    summary: "开场 hook、场景切换、章尾钩子、单章场景数、叙述与对白配比",
+  },
+  {
+    key: "cognitiveFrame",
+    level: "L3-L5",
+    label: "叙事视角与认知框架",
+    fileName: "叙事视角与认知框架.md",
+    summary: "推进取舍、细节素材通道、作者价值判断与反复命题",
+  },
+  {
+    key: "rhythmGuide",
+    level: "L6",
+    label: "排版与节奏指南",
+    fileName: "排版节奏指南.md",
+    summary: "段落长度节奏、一句一段用法、分隔方式、章标题格式",
+  },
+] as const satisfies ReadonlyArray<{
+  key: keyof WritingDnaLayers
+  level: string
+  label: string
+  fileName: string
+  summary: string
+}>
+
+export const INTEGRATED_DNA_FILE_NAME = "Writing-DNA.md"
+/** 整合文档字数上限:超过说明没蒸馏干净(沿用 writing-dna-skill 的质量标准)。 */
+export const INTEGRATED_DNA_CHAR_LIMIT = 4000
+
+/** v1 的 9 个维度归属到哪一层——迁移时按此拼接。 */
+const V1_DIMENSION_LAYER_MAP: Array<{
+  key: keyof BookStyleProfile
+  label: string
+  layer: keyof WritingDnaLayers
+}> = [
+  { key: "narrativeDensity", label: "叙事密度 / 节奏", layer: "languageDna" },
+  { key: "sentenceStyle", label: "句式与句长 / 口语化程度", layer: "languageDna" },
+  { key: "rhetoricDensity", label: "比喻 / 通感密度", layer: "languageDna" },
+  { key: "dialogueStyle", label: "对白风格", layer: "languageDna" },
+  { key: "transitionStyle", label: "场景与时间过渡方式", layer: "structurePatterns" },
+  { key: "descriptionWeight", label: "环境描写比重", layer: "structurePatterns" },
+  { key: "emotionRendering", label: "情绪呈现", layer: "structurePatterns" },
+  { key: "thematicHabits", label: "点题 / 总结 / 抒情习惯", layer: "structurePatterns" },
+  { key: "narrativeVoice", label: "叙述视角与声音", layer: "cognitiveFrame" },
+  { key: "pointOfView", label: "叙事人称与限知范围", layer: "cognitiveFrame" },
+]
+
+export function emptyWritingDnaLayers(): WritingDnaLayers {
+  return { languageDna: "", structurePatterns: "", cognitiveFrame: "", rhythmGuide: "" }
+}
+
+/** v2 判定:有 layers 且至少一层非空,或者有 integratedDna。 */
+export function isWritingDnaProfile(profile: BookStyleProfile | null | undefined): boolean {
+  if (!profile) return false
+  if (profile.integratedDna?.trim()) return true
+  const layers = profile.layers
+  if (!layers) return false
+  return Object.values(layers).some((value) => typeof value === "string" && value.trim().length > 0)
+}
+
+function asText(value: unknown): string {
+  return typeof value === "string" ? value.trim() : ""
+}
+
+/**
+ * 把 v1 profile 的 9 个维度按层归类,拼成 layers 文本。
+ * 只搬运已有内容;rhythmGuide 在 v1 里没有对应维度,留空并由调用方按需重跑。
+ */
+function layersFromV1Dimensions(profile: BookStyleProfile): WritingDnaLayers {
+  const buckets = emptyWritingDnaLayers()
+  const collected: Record<keyof WritingDnaLayers, string[]> = {
+    languageDna: [],
+    structurePatterns: [],
+    cognitiveFrame: [],
+    rhythmGuide: [],
+  }
+  for (const item of V1_DIMENSION_LAYER_MAP) {
+    const text = asText(profile[item.key])
+    if (text) collected[item.layer].push(`**${item.label}**:${text}`)
+  }
+  if (profile.vocabularyPreferences?.length) {
+    collected.languageDna.push(`**词汇偏好**:${profile.vocabularyPreferences.join("、")}`)
+  }
+  if (profile.humorMechanisms?.length) {
+    collected.cognitiveFrame.push(`**幽默机制**:${profile.humorMechanisms.join(";")}`)
+  }
+  if (profile.highEnergyMechanisms?.length) {
+    collected.cognitiveFrame.push(`**热血机制**:${profile.highEnergyMechanisms.join(";")}`)
+  }
+  if (profile.avoidPatterns?.length) {
+    collected.structurePatterns.push(`**应避免的写法**:${profile.avoidPatterns.join(";")}`)
+  }
+  for (const key of Object.keys(collected) as Array<keyof WritingDnaLayers>) {
+    buckets[key] = collected[key].join("\n\n")
+  }
+  return buckets
+}
+
+/**
+ * 读盘后统一入口:把任意版本的 profile 规范化成 v2 形状。
+ * v1 数据迁移后 integratedDna 仍为空,注入侧会降级用 constitution,
+ * 并在 UI 上提示"由旧版画像迁移,建议重新提取"。
+ */
+export function migrateStyleProfile(input: BookStyleProfile): BookStyleProfile {
+  const layers = input.layers && Object.keys(input.layers).length > 0
+    ? { ...emptyWritingDnaLayers(), ...input.layers }
+    : layersFromV1Dimensions(input)
+
+  return {
+    ...input,
+    schemaVersion: 2,
+    layers,
+    metrics: input.metrics ?? computeStyleMetrics([]),
+    integratedDna: asText(input.integratedDna),
+    constitution: asText(input.constitution),
+    samples: Array.isArray(input.samples) ? input.samples : [],
+    sampledChapterIds: Array.isArray(input.sampledChapterIds) ? input.sampledChapterIds : [],
+  }
+}
+
+/** 迁移而来但没有真正 v2 内容的画像——UI 应提示重新提取。 */
+export function needsReextraction(profile: BookStyleProfile | null | undefined): boolean {
+  if (!profile) return false
+  return !isWritingDnaProfile(profile) || !profile.metrics || profile.metrics.counts.chapters === 0
+}

+ 0 - 63
src/lib/novel/book-analysis/style-prompts.spec.ts

@@ -1,63 +0,0 @@
-import { describe, expect, it } from "vitest"
-import {
-  buildStyleExtractionPrompt,
-  parseStyleProfileResult,
-  FALLBACK_STYLE_CONSTITUTION,
-} from "./style-prompts"
-
-describe("parseStyleProfileResult", () => {
-  it("parses a clean JSON object and keeps sampled chapter ids", () => {
-    const raw = JSON.stringify({
-      narrativeDensity: "密度高、推进快",
-      descriptionWeight: "环境描写少且具体",
-      constitution: "1. 朴素\n2. 克制",
-      samples: ["原文片段一", "原文片段二"],
-    })
-    const profile = parseStyleProfileResult(raw, ["ch-0001", "ch-0050"])
-
-    expect(profile.narrativeDensity).toBe("密度高、推进快")
-    expect(profile.descriptionWeight).toBe("环境描写少且具体")
-    expect(profile.constitution).toBe("1. 朴素\n2. 克制")
-    expect(profile.samples).toEqual(["原文片段一", "原文片段二"])
-    expect(profile.sampledChapterIds).toEqual(["ch-0001", "ch-0050"])
-  })
-
-  it("strips ```json fences and extracts the object", () => {
-    const raw = "分析完成,结果如下:\n```json\n" + JSON.stringify({ constitution: "宪法X", samples: [] }) + "\n```"
-    expect(parseStyleProfileResult(raw, []).constitution).toBe("宪法X")
-  })
-
-  it("extracts the trailing JSON even when prose with brackets precedes it", () => {
-    const raw = "我读了样本[第1章],下面是 JSON:" + JSON.stringify({ constitution: "宪法Y", samples: ["s"] })
-    const profile = parseStyleProfileResult(raw, [])
-    expect(profile.constitution).toBe("宪法Y")
-    expect(profile.samples).toEqual(["s"])
-  })
-
-  it("falls back to the default constitution on non-JSON output", () => {
-    expect(parseStyleProfileResult("这里没有 JSON", []).constitution).toBe(FALLBACK_STYLE_CONSTITUTION)
-  })
-
-  it("falls back to the default constitution when the field is missing", () => {
-    const raw = JSON.stringify({ narrativeDensity: "高" })
-    const profile = parseStyleProfileResult(raw, [])
-    expect(profile.constitution).toBe(FALLBACK_STYLE_CONSTITUTION)
-    expect(profile.narrativeDensity).toBe("高")
-  })
-
-  it("caps samples at 6", () => {
-    const raw = JSON.stringify({ constitution: "x", samples: Array.from({ length: 10 }, (_, i) => `s${i}`) })
-    expect(parseStyleProfileResult(raw, []).samples).toHaveLength(6)
-  })
-})
-
-describe("buildStyleExtractionPrompt", () => {
-  it("includes the book title, dimension keys and the samples field instruction", () => {
-    const prompt = buildStyleExtractionPrompt("一段原文样本。", "凡人修仙传")
-    expect(prompt).toContain("凡人修仙传")
-    expect(prompt).toContain("narrativeDensity")
-    expect(prompt).toContain("constitution")
-    expect(prompt).toContain("samples")
-    expect(prompt).toContain("一段原文样本。")
-  })
-})

+ 0 - 180
src/lib/novel/book-analysis/style-prompts.ts

@@ -1,180 +0,0 @@
-/**
- * 作品级写作文风提取 - 提示词 + 解析(feature/book-style-extraction)
- *
- * 目标:从抽样章节正文提炼"作者的叙事文风"(不是人物说话方式):
- * 叙事密度、描写克制度、情绪呈现、句式、比喻频率、过渡、视角、对白、点题习惯。
- * 产出结构化维度 + 可注入的"风格宪法" + 3~6 段代表原文样本(few-shot)。
- */
-import type { BookStyleProfile } from "./types"
-
-/** 结构化文风维度(key 与 BookStyleProfile 字段对应,label 供 UI / 提示词使用)。 */
-export const STYLE_DIMENSIONS = [
-  { key: "narrativeDensity", label: "叙事密度 / 节奏" },
-  { key: "descriptionWeight", label: "环境描写比重(具体 vs 抒情)" },
-  { key: "emotionRendering", label: "情绪呈现(动作外显 vs 内心独白;克制度)" },
-  { key: "sentenceStyle", label: "句式与句长 / 口语化程度" },
-  { key: "rhetoricDensity", label: "比喻 / 通感密度" },
-  { key: "transitionStyle", label: "场景与时间过渡方式" },
-  { key: "narrativeVoice", label: "叙述视角与声音" },
-  { key: "dialogueStyle", label: "对白风格(口语 / 毛边 / 潜台词)" },
-  { key: "thematicHabits", label: "点题 / 总结 / 抒情习惯" },
-] as const
-
-interface StyleEvidenceCandidate {
-  chapterId: string
-  text: string
-  tags: string[]
-  reason: string
-  purpose: string
-}
-
-const SAMPLE_TEXT_LIMIT = 24000
-
-function truncate(value: string, limit: number): string {
-  if (!value) return ""
-  if (value.length <= limit) return value
-  return value.slice(0, limit) + "\n\n…(样本过长已截断)…"
-}
-
-/**
- * 兜底"通用朴素文风宪法":解析失败 / 无 LLM 时使用,避免阻断。
- * 不依赖任何具体作品,描述的是"去炫技、重推进"的网文朴素风。
- */
-export const FALLBACK_STYLE_CONSTITUTION = [
-  "1. 叙事优先:每段都要推进剧情、信息或关系,不为氛围而写氛围。",
-  "2. 环境描写克制:只写与当前动作/信息相关的具体实物,一般 1-2 句,禁止连续景物抒情。",
-  "3. 情绪用动作和具体细节呈现,禁止“他感到/她心中/五味杂陈”式总结,点到即止。",
-  "4. 句式以短句和中短句为主,禁止长比喻链、通感堆叠、排比堆字。",
-  "5. 比喻克制:每千字不超过 1-2 处,且用日常化喻体。",
-  "6. 过渡直接(“第二天”“三天后”),不写“时间一分一秒过去”式空转。",
-  "7. 推进要快:能一句带过的时间、路程、重复动作就不展开。",
-  "8. 不点题、不抒情、不在段尾总结,结尾只留钩子。",
-].join("\n")
-
-export function buildStyleExtractionPrompt(sampleText: string, bookTitle: string): string {
-  const dimensionLines = STYLE_DIMENSIONS.map((d) => `- ${d.key}:${d.label}`).join("\n")
-  return [
-    `你是小说文风分析专家。请仔细阅读《${bookTitle}》的若干章节原文样本,提炼这本书**作者的叙事文风**(不是人物的说话方式,也不要复述剧情)。`,
-    "",
-    "你要分析的维度:",
-    dimensionLines,
-    "",
-    "只输出一个 JSON 对象,不要解释,不要 markdown 代码围栏。字段要求:",
-    "1. 上述每个维度 key 都要有一个**详细**的中文字符串值(3-6 句),深入描述这本书在该维度上的具体特征。必须包含:",
-    "   - 该维度的核心特征是什么",
-    `   - 作者具体是怎么做的(举例说明,如\u201C常用XX句式\u201D\u201C倾向于XX方式\u201D)`,
-    "   - 与常见写法的差异点",
-    `   绝不要泛泛而谈(如\u201C节奏适中\u201D\u201C描写生动\u201D),必须具体到可指导写作的程度。`,
-    `2. \`constitution\`:把以上维度合成为 8~12 条**可执行的硬约束**(编号列表,写进同一个字符串,用换行分隔),用于约束 AI 之后按这种文风写作。每条约束必须具体到\u201C怎么写/不怎么写\u201D,并附带简短的理由,例如\u201C环境描写控制在2句以内,因为作者偏好用动作带出场景而非静态铺陈\u201D\u201C比喻只用日常喻体,因为作者的比喻总是接地气的而非文学化的\u201D。`,
-    "3. `samples`:从上面提供的原文样本中,**原样摘抄** 4~6 段最能代表这种文风的片段(每段 80~300 字),放进字符串数组。必须是原文照抄,不要改写、不要自己编。优先选择能同时体现多个维度特征的段落。",
-    "4. `humorMechanisms`:具体说明幽默如何产生,例如反差、一本正经地补刀、误解递进;没有明显幽默时返回空数组。",
-    "5. `highEnergyMechanisms`:具体说明热血或高燃段落如何抬压、加速和兑现;没有明显特征时返回空数组。",
-    "6. `pointOfView`:明确叙事人称与限知范围。",
-    "7. `vocabularyPreferences`:列出稳定的词汇、动词和口语偏好。",
-    "8. `avoidPatterns`:列出仿写时应避免的写法。",
-    "9. `evidence`:返回 3~8 个代表片段,每项包含 chapterId、text、tags、reason、purpose。chapterId 必须来自样本标题中的章节 ID,text 必须是 80~300 字原文短片段,不得改写。",
-    "",
-    "硬性要求:",
-    "- 只分析文风,不要分析人物性格或剧情走向。",
-    "- 每个维度的描述必须包含具体的写作手法和用词习惯,不能只给笼统评价。",
-    "- constitution 中的每条约束都必须是可操作的写作指令,不能是空泛的原则。",
-    "- samples 必须来自提供的原文,不得虚构。",
-    "- evidence 的 tags 要标明幽默对白、热血节奏、叙事视角、词汇习惯等可检索特征。",
-    "- JSON 之外不要输出任何内容。",
-    "",
-    `作品:${bookTitle}`,
-    "",
-    "原文样本(已截断):",
-    truncate(sampleText, SAMPLE_TEXT_LIMIT),
-  ].join("\n")
-}
-
-function asString(value: unknown): string {
-  return typeof value === "string" ? value.trim() : ""
-}
-
-function asStringArray(value: unknown): string[] {
-  if (!Array.isArray(value)) return []
-  return value.map((item) => asString(item)).filter(Boolean)
-}
-
-export function parseStyleEvidenceResult(raw: string): StyleEvidenceCandidate[] {
-  const fenceStripped = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] ?? raw
-  const objectText = fenceStripped.match(/\{[\s\S]*\}/)?.[0]
-  if (!objectText) return []
-  try {
-    const parsed = JSON.parse(objectText) as Record<string, unknown>
-    if (!Array.isArray(parsed.evidence)) return []
-    return parsed.evidence.flatMap((item): StyleEvidenceCandidate[] => {
-      if (!item || typeof item !== "object") return []
-      const candidate = item as Record<string, unknown>
-      const chapterId = asString(candidate.chapterId)
-      const text = asString(candidate.text)
-      if (!chapterId || !text) return []
-      return [{
-        chapterId,
-        text,
-        tags: asStringArray(candidate.tags),
-        reason: asString(candidate.reason),
-        purpose: asString(candidate.purpose),
-      }]
-    }).slice(0, 8)
-  } catch {
-    return []
-  }
-}
-
-/**
- * 解析模型返回的文风 JSON。容错:剥代码围栏 + 取最外层 {...}。
- * 解析失败或缺 constitution 时,返回带兜底宪法的最小 profile(不抛错)。
- */
-export function parseStyleProfileResult(raw: string, sampledChapterIds: string[]): BookStyleProfile {
-  const base: BookStyleProfile = {
-    schemaVersion: 1,
-    generatedAt: 0, // 由调用方在返回后盖时间戳(避免 Date 在纯函数里)
-    sampledChapterIds,
-    narrativeDensity: "",
-    descriptionWeight: "",
-    emotionRendering: "",
-    sentenceStyle: "",
-    rhetoricDensity: "",
-    transitionStyle: "",
-    narrativeVoice: "",
-    dialogueStyle: "",
-    thematicHabits: "",
-    constitution: FALLBACK_STYLE_CONSTITUTION,
-    samples: [],
-  }
-
-  const fenceStripped = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] ?? raw
-  const objectText = fenceStripped.match(/\{[\s\S]*\}/)?.[0]
-  if (!objectText) return base
-
-  let parsed: Record<string, unknown>
-  try {
-    parsed = JSON.parse(objectText) as Record<string, unknown>
-  } catch {
-    return base
-  }
-
-  const constitution = asString(parsed.constitution)
-  return {
-    ...base,
-    narrativeDensity: asString(parsed.narrativeDensity),
-    descriptionWeight: asString(parsed.descriptionWeight),
-    emotionRendering: asString(parsed.emotionRendering),
-    sentenceStyle: asString(parsed.sentenceStyle),
-    rhetoricDensity: asString(parsed.rhetoricDensity),
-    transitionStyle: asString(parsed.transitionStyle),
-    narrativeVoice: asString(parsed.narrativeVoice),
-    dialogueStyle: asString(parsed.dialogueStyle),
-    thematicHabits: asString(parsed.thematicHabits),
-    humorMechanisms: asStringArray(parsed.humorMechanisms),
-    highEnergyMechanisms: asStringArray(parsed.highEnergyMechanisms),
-    pointOfView: asString(parsed.pointOfView),
-    vocabularyPreferences: asStringArray(parsed.vocabularyPreferences),
-    avoidPatterns: asStringArray(parsed.avoidPatterns),
-    constitution: constitution || FALLBACK_STYLE_CONSTITUTION,
-    samples: asStringArray(parsed.samples).slice(0, 6),
-  }
-}

+ 40 - 15
src/lib/novel/book-analysis/types.ts

@@ -1,4 +1,5 @@
 
+import type { StyleMetrics } from "./style-metrics"
 
 /** 6 维度分析深度档位(feature/book-analysis-6d-skill) */
 export type AnalysisDepth = "fast" | "standard" | "deep"
@@ -227,33 +228,57 @@ export interface SimpleExtractionMeta {
   schemaVersion: 1
 }
 
-// === 作品级写作文风(feature/book-style-extraction)===
+// === 作品级写作文风(feature/book-style-extraction → feature/writing-dna)===
+/**
+ * 分层蒸馏产物(writing-dna 的 L1-L6,已映射到小说语境)。
+ * 每层是一段可直接写入 markdown 的中文说明;整合文档由 integratedDna 承载。
+ */
+export interface WritingDnaLayers {
+  /** L1 语言 DNA:模型对确定性统计(句长、标点、词频)的解读 */
+  languageDna: string
+  /** L2 章节结构模板:开场 hook、场景切换、章尾钩子、单章场景数 */
+  structurePatterns: string
+  /** L3-L5 叙事视角与认知框架:推进取舍、细节素材、作者价值判断 */
+  cognitiveFrame: string
+  /** L6 排版与节奏:段落长度节奏、一句一段用法、分隔方式 */
+  rhythmGuide: string
+}
+
 /**
  * 从一本拆书作品提取的"作品级叙事文风"画像。
- * 区别于角色灵魂(人物的说话方式),这是作者的整体叙事风格:
- * 叙事密度、描写克制度、句式、比喻频率、过渡方式等。
- * constitution 是注入生成用的硬约束;samples 是 few-shot 代表原文片段。
+ * 区别于角色灵魂(人物的说话方式),这是作者的整体叙事风格。
+ *
+ * schemaVersion 2 起改为 writing-dna 分层蒸馏:metrics 是脚本统计的确定性数字,
+ * layers 是 L1-L6 分层解读,integratedDna 是注入生成的总入口(上限约 4000 字)。
+ * v1 的 9 个维度字段全部保留为可选,既能读旧数据,也让旧 UI 与校验引擎继续工作。
  */
 export interface BookStyleProfile {
-  schemaVersion: 1
+  schemaVersion: 1 | 2
   generatedAt: number
   sampledChapterIds: string[]
-  narrativeDensity: string
-  descriptionWeight: string
-  emotionRendering: string
-  sentenceStyle: string
-  rhetoricDensity: string
-  transitionStyle: string
-  narrativeVoice: string
-  dialogueStyle: string
-  thematicHabits: string
+  /** v2:L1 / L6 的确定性统计结果(不调 LLM 算出)。 */
+  metrics?: StyleMetrics
+  /** v2:L1-L6 分层蒸馏产物。 */
+  layers?: WritingDnaLayers
+  /** v2:整合文档正文,注入生成时的首选内容。 */
+  integratedDna?: string
+  /** v1 维度,v2 仍会尽量填充以兼容旧 UI 与 verification-engine。 */
+  narrativeDensity?: string
+  descriptionWeight?: string
+  emotionRendering?: string
+  sentenceStyle?: string
+  rhetoricDensity?: string
+  transitionStyle?: string
+  narrativeVoice?: string
+  dialogueStyle?: string
+  thematicHabits?: string
   humorMechanisms?: string[]
   highEnergyMechanisms?: string[]
   pointOfView?: string
   vocabularyPreferences?: string[]
   avoidPatterns?: string[]
   evidenceIds?: string[]
-  /** 5~8 条注入用"风格宪法"硬约束(由上面维度合成)。 */
+  /** 8~12 条注入用"风格宪法"硬约束。v2 仍保留,作为 integratedDna 缺失时的降级内容。 */
   constitution: string
   /** 3~6 段代表原文片段,作为模仿锚点(few-shot)。 */
   samples: string[]

+ 16 - 1
src/lib/novel/book-analysis/verification-engine.ts

@@ -11,6 +11,7 @@ import { createDirectory, fileExists, listDirectory, readFile, writeFile } from
 import { joinPath } from "@/lib/path-utils"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
 import type { ExtractedCharacter } from "./types"
+import { isWritingDnaProfile, migrateStyleProfile } from "./style-profile-schema"
 import type { BookStyleProfile } from "./types"
 import type { StoryMap } from "./story-map-types"
 import { STORY_BRANCH_KIND_LABELS } from "./story-map-types"
@@ -89,6 +90,18 @@ function characterProfileText(character: ExtractedCharacter): string {
 }
 
 function styleProfileText(profile: BookStyleProfile): string {
+  // v2 优先审计分层产物与整合文档;v1 数据(或分层缺失)才回落到 9 个维度
+  if (isWritingDnaProfile(profile)) {
+    const layers = profile.layers
+    return [
+      profile.integratedDna?.trim() ? `整合文档:\n${profile.integratedDna}` : "",
+      layers?.languageDna ? `L1 语言 DNA:\n${layers.languageDna}` : "",
+      layers?.structurePatterns ? `L2 章节结构:\n${layers.structurePatterns}` : "",
+      layers?.cognitiveFrame ? `L3-L5 认知框架:\n${layers.cognitiveFrame}` : "",
+      layers?.rhythmGuide ? `L6 排版与节奏:\n${layers.rhythmGuide}` : "",
+      `风格宪法:\n${profile.constitution}`,
+    ].filter(Boolean).join("\n\n")
+  }
   return [
     `叙事密度:${profile.narrativeDensity}`,
     `描写比重:${profile.descriptionWeight}`,
@@ -315,7 +328,9 @@ export function createVerificationEngine(
       if (!await dependencies.fileExists(joinPath(bookPath, "style-profile.json"))) {
         return reportForMissingSource(skill, bookPath, now)
       }
-      const profile = JSON.parse(await readFile(joinPath(bookPath, "style-profile.json"))) as BookStyleProfile
+      const profile = migrateStyleProfile(
+        JSON.parse(await readFile(joinPath(bookPath, "style-profile.json"))) as BookStyleProfile,
+      )
       units.push(await verifyUnit(
         "style",
         "作品文风",

+ 272 - 0
src/lib/novel/book-analysis/writing-dna-prompts.spec.ts

@@ -0,0 +1,272 @@
+import { describe, expect, it } from "vitest"
+import { computeStyleMetrics } from "./style-metrics"
+import {
+  FALLBACK_STYLE_CONSTITUTION,
+  buildCognitiveFramePrompt,
+  buildDnaIntegrationPrompt,
+  buildLanguageAndRhythmPrompt,
+  buildLayerAggregatePrompt,
+  buildStructurePatternPrompt,
+  parseCognitiveFrameResult,
+  parseIntegratedDnaResult,
+  parseLanguageAndRhythmResult,
+  parseLayerAggregateResult,
+  parseStructurePatternResult,
+  parseStyleEvidenceResult,
+  stripConstitutionSection,
+} from "./writing-dna-prompts"
+
+const metrics = computeStyleMetrics(["他走了。她没动。\n\n“别过来。”"])
+
+describe("buildLanguageAndRhythmPrompt", () => {
+  it("带上书名、统计数字与原文样本", () => {
+    const prompt = buildLanguageAndRhythmPrompt(metrics, "一段原文样本。", "凡人修仙传")
+    expect(prompt).toContain("凡人修仙传")
+    expect(prompt).toContain("脚本统计结果")
+    expect(prompt).toContain("平均句长")
+    expect(prompt).toContain("一段原文样本。")
+    expect(prompt).toContain("languageDna")
+    expect(prompt).toContain("rhythmGuide")
+  })
+
+  it("明确要求模型不要重新估算统计数字", () => {
+    expect(buildLanguageAndRhythmPrompt(metrics, "样本", "书")).toContain("不要重新估算")
+  })
+
+  it("分词降级时把局限性写进 prompt", () => {
+    const degraded = { ...metrics, segmenterAvailable: false }
+    const prompt = buildLanguageAndRhythmPrompt(degraded, "样本", "书")
+    expect(prompt).toContain("缺少中文分词能力")
+    expect(prompt).toContain("说明其局限")
+  })
+
+  it("截断过长样本", () => {
+    const prompt = buildLanguageAndRhythmPrompt(metrics, "字".repeat(30000), "书")
+    expect(prompt).toContain("样本过长已截断")
+  })
+})
+
+describe("parseLanguageAndRhythmResult", () => {
+  it("解析干净的 JSON", () => {
+    const raw = JSON.stringify({
+      languageDna: "短句为主",
+      rhythmGuide: "一句一段",
+      sentenceStyle: "口语",
+      vocabularyPreferences: ["回头", "愣住"],
+      avoidPatterns: ["长比喻链"],
+    })
+    const result = parseLanguageAndRhythmResult(raw)
+    expect(result.languageDna).toBe("短句为主")
+    expect(result.rhythmGuide).toBe("一句一段")
+    expect(result.vocabularyPreferences).toEqual(["回头", "愣住"])
+    expect(result.avoidPatterns).toEqual(["长比喻链"])
+  })
+
+  it("剥掉 ```json 围栏", () => {
+    const raw = "结果:\n```json\n" + JSON.stringify({ languageDna: "X" }) + "\n```"
+    expect(parseLanguageAndRhythmResult(raw).languageDna).toBe("X")
+  })
+
+  it("前置含方括号的散文时仍能取到 JSON", () => {
+    const raw = "我读了样本[第1章],结果:" + JSON.stringify({ languageDna: "Y" })
+    expect(parseLanguageAndRhythmResult(raw).languageDna).toBe("Y")
+  })
+
+  it("非 JSON 输出返回空值而不抛错", () => {
+    const result = parseLanguageAndRhythmResult("这里没有 JSON")
+    expect(result.languageDna).toBe("")
+    expect(result.vocabularyPreferences).toEqual([])
+  })
+})
+
+describe("buildStructurePatternPrompt / parseStructurePatternResult", () => {
+  it("prompt 要求章节标注覆盖每一章", () => {
+    const prompt = buildStructurePatternPrompt("样本", "书")
+    expect(prompt).toContain("chapterMeta")
+    expect(prompt).toContain("必须覆盖样本里出现的每一个章节 ID")
+    expect(prompt).toContain("evidence")
+  })
+
+  it("解析结构、样本与章节标注", () => {
+    const raw = JSON.stringify({
+      structurePatterns: "推进章骨架",
+      transitionStyle: "直接跳时间",
+      samples: ["片段"],
+      chapterMeta: [
+        { chapterId: "ch-0001", hookType: "冲突式", structurePattern: "推进章", topicTags: ["试炼"] },
+        { chapterId: "", hookType: "场景式" },
+      ],
+    })
+    const result = parseStructurePatternResult(raw)
+    expect(result.structurePatterns).toBe("推进章骨架")
+    expect(result.samples).toEqual(["片段"])
+    expect(result.chapterMeta).toHaveLength(1)
+    expect(result.chapterMeta[0].topicTags).toEqual(["试炼"])
+  })
+
+  it("samples 上限 6 条", () => {
+    const raw = JSON.stringify({ samples: Array.from({ length: 10 }, (_, i) => `s${i}`) })
+    expect(parseStructurePatternResult(raw).samples).toHaveLength(6)
+  })
+
+  it("chapterMeta 缺失时返回空数组", () => {
+    expect(parseStructurePatternResult(JSON.stringify({ structurePatterns: "x" })).chapterMeta).toEqual([])
+  })
+})
+
+describe("buildCognitiveFramePrompt / parseCognitiveFrameResult", () => {
+  it("prompt 要求三个小节与不显而易见的命题", () => {
+    const prompt = buildCognitiveFramePrompt("样本", "书")
+    expect(prompt).toContain("推进取舍(L3)")
+    expect(prompt).toContain("细节素材(L4)")
+    expect(prompt).toContain("价值判断(L5)")
+    expect(prompt).toContain("不显而易见")
+  })
+
+  it("解析认知框架与机制数组", () => {
+    const raw = JSON.stringify({
+      cognitiveFrame: "### 推进取舍(L3)\n能一句带过就不展开。",
+      pointOfView: "第三人称限知",
+      humorMechanisms: ["补刀"],
+      highEnergyMechanisms: [],
+    })
+    const result = parseCognitiveFrameResult(raw)
+    expect(result.cognitiveFrame).toContain("推进取舍")
+    expect(result.pointOfView).toBe("第三人称限知")
+    expect(result.humorMechanisms).toEqual(["补刀"])
+    expect(result.highEnergyMechanisms).toEqual([])
+  })
+})
+
+describe("buildDnaIntegrationPrompt", () => {
+  const layers = {
+    languageDna: "短句为主",
+    structurePatterns: "推进章骨架",
+    cognitiveFrame: "能一句带过就不展开",
+    rhythmGuide: "一句一段",
+  }
+
+  it("要求输出 markdown 且带字数上限与硬约束小节", () => {
+    const prompt = buildDnaIntegrationPrompt(layers, metrics, "凡人修仙传")
+    expect(prompt).toContain("不超过 4000 字")
+    expect(prompt).toContain("不要 JSON")
+    expect(prompt).toContain("## 风格硬约束")
+    expect(prompt).toContain("短句为主")
+    expect(prompt).toContain("推进章骨架")
+  })
+
+  it("某层缺失时标注未提取而不是留白", () => {
+    const prompt = buildDnaIntegrationPrompt({ ...layers, rhythmGuide: "" }, metrics, "书")
+    expect(prompt).toContain("(未提取)")
+  })
+})
+
+describe("parseIntegratedDnaResult", () => {
+  const markdown = [
+    "## 语言特征",
+    "短句为主。",
+    "",
+    "## 风格硬约束",
+    "1. 环境描写不超过 2 句,因为作者用动作带出场景。",
+    "2. 比喻只用日常喻体。",
+  ].join("\n")
+
+  it("整篇作为整合文档,硬约束小节单独抽出", () => {
+    const result = parseIntegratedDnaResult(markdown)
+    expect(result.integratedDna).toContain("## 语言特征")
+    expect(result.constitution).toContain("环境描写不超过 2 句")
+    expect(result.constitution).toContain("比喻只用日常喻体")
+    expect(result.constitution).not.toContain("短句为主")
+  })
+
+  it("剥掉 markdown 代码围栏", () => {
+    const result = parseIntegratedDnaResult("```markdown\n" + markdown + "\n```")
+    expect(result.integratedDna.startsWith("## 语言特征")).toBe(true)
+  })
+
+  it("没有硬约束小节时兜底抓编号行", () => {
+    const result = parseIntegratedDnaResult("## 语言特征\n1. 用短句。\n2. 少比喻。")
+    expect(result.constitution).toContain("用短句")
+    expect(result.constitution).toContain("少比喻")
+  })
+
+  it("空输出时回落到通用宪法", () => {
+    const result = parseIntegratedDnaResult("   ")
+    expect(result.integratedDna).toBe("")
+    expect(result.constitution).toBe(FALLBACK_STYLE_CONSTITUTION)
+  })
+
+  it("既无小节也无编号行时回落到通用宪法", () => {
+    const result = parseIntegratedDnaResult("## 语言特征\n就是一段散文,没有任何编号。")
+    expect(result.constitution).toBe(FALLBACK_STYLE_CONSTITUTION)
+  })
+})
+
+describe("stripConstitutionSection", () => {
+  it("摘掉硬约束小节,保留其余小节", () => {
+    const body = stripConstitutionSection([
+      "## 语言特征",
+      "短句为主。",
+      "",
+      "## 风格硬约束",
+      "1. 环境描写不超过 2 句。",
+      "",
+      "## 排版与节奏",
+      "一句一段。",
+    ].join("\n"))
+    expect(body).toContain("短句为主")
+    expect(body).toContain("一句一段")
+    expect(body).not.toContain("环境描写不超过 2 句")
+  })
+
+  it("硬约束在末尾时也能摘掉", () => {
+    const body = stripConstitutionSection("## 语言特征\n短句为主。\n\n## 风格硬约束\n1. 用短句。")
+    expect(body).toContain("短句为主")
+    expect(body).not.toContain("用短句")
+  })
+
+  it("没有该小节时原样返回", () => {
+    expect(stripConstitutionSection("## 语言特征\n短句为主。")).toBe("## 语言特征\n短句为主。")
+  })
+})
+
+describe("parseStyleEvidenceResult", () => {
+  it("解析证据并跳过缺字段的项", () => {
+    const raw = JSON.stringify({
+      evidence: [
+        { chapterId: "ch-0001", text: "片段", tags: ["钩子"], reason: "r", purpose: "p" },
+        { chapterId: "", text: "片段" },
+        { chapterId: "ch-0002", text: "" },
+      ],
+    })
+    const result = parseStyleEvidenceResult(raw)
+    expect(result).toHaveLength(1)
+    expect(result[0].tags).toEqual(["钩子"])
+  })
+
+  it("上限 8 条", () => {
+    const raw = JSON.stringify({
+      evidence: Array.from({ length: 12 }, (_, i) => ({ chapterId: `ch-${i}`, text: "片段" })),
+    })
+    expect(parseStyleEvidenceResult(raw)).toHaveLength(8)
+  })
+
+  it("非 JSON 返回空数组", () => {
+    expect(parseStyleEvidenceResult("没有 JSON")).toEqual([])
+  })
+})
+
+describe("buildLayerAggregatePrompt / parseLayerAggregateResult", () => {
+  it("列出所有区块并要求剔除偶发特征", () => {
+    const prompt = buildLayerAggregatePrompt("L1 语言 DNA", ["区块甲", "区块乙"], "凡人修仙传")
+    expect(prompt).toContain("L1 语言 DNA")
+    expect(prompt).toContain("区块 1")
+    expect(prompt).toContain("区块 2")
+    expect(prompt).toContain("偶发特征")
+  })
+
+  it("解析时剥围栏并去空白", () => {
+    expect(parseLayerAggregateResult("```md\n合并结果\n```")).toBe("合并结果")
+    expect(parseLayerAggregateResult("  合并结果  ")).toBe("合并结果")
+  })
+})

+ 408 - 0
src/lib/novel/book-analysis/writing-dna-prompts.ts

@@ -0,0 +1,408 @@
+/**
+ * Writing DNA 分层蒸馏的提示词与解析(feature/writing-dna)
+ *
+ * 取代原来"一次 LLM 出 9 个主观维度"的做法,改成 writing-dna 的分层蒸馏:
+ *   L1 语言 + L6 排版节奏:把 style-metrics 算出的确定性数字喂给模型解读,不让它猜句长
+ *   L2 章节结构:开场 hook、场景切换、章尾钩子、单章场景数
+ *   L3-L5 认知框架:推进取舍、细节素材通道、作者价值判断
+ *   整合:产出 Writing-DNA.md 正文(markdown,不是 JSON)+ 可执行风格硬约束
+ *
+ * 前三步输出 JSON 便于结构化落盘;整合步输出 markdown,避免 4000 字长文走 JSON 转义时被截断。
+ */
+import type { ChapterMetaAnnotation } from "./chapter-meta"
+import { formatStyleMetricsForPrompt, type StyleMetrics } from "./style-metrics"
+import { INTEGRATED_DNA_CHAR_LIMIT } from "./style-profile-schema"
+import type { WritingDnaLayers } from "./types"
+
+const SAMPLE_TEXT_LIMIT = 24000
+const CONSTITUTION_SECTION_TITLE = "风格硬约束"
+
+export interface StyleEvidenceCandidate {
+  chapterId: string
+  text: string
+  tags: string[]
+  reason: string
+  purpose: string
+}
+
+/** L1 + L6 一次调用的产出。 */
+export interface LanguageAndRhythmResult {
+  languageDna: string
+  rhythmGuide: string
+  narrativeDensity: string
+  sentenceStyle: string
+  rhetoricDensity: string
+  dialogueStyle: string
+  vocabularyPreferences: string[]
+  avoidPatterns: string[]
+}
+
+/** L2 一次调用的产出(顺带返回每章标注,避免为 chapter-meta 再加一次调用)。 */
+export interface StructurePatternResult {
+  structurePatterns: string
+  transitionStyle: string
+  descriptionWeight: string
+  emotionRendering: string
+  thematicHabits: string
+  samples: string[]
+  chapterMeta: ChapterMetaAnnotation[]
+}
+
+/** L3-L5 一次调用的产出。 */
+export interface CognitiveFrameResult {
+  cognitiveFrame: string
+  narrativeVoice: string
+  pointOfView: string
+  humorMechanisms: string[]
+  highEnergyMechanisms: string[]
+}
+
+/** 整合步的产出。 */
+export interface IntegratedDnaResult {
+  integratedDna: string
+  constitution: string
+}
+
+/**
+ * 兜底"通用朴素文风宪法":解析失败 / 无 LLM 时使用,避免阻断。
+ * 不依赖任何具体作品,描述的是"去炫技、重推进"的网文朴素风。
+ */
+export const FALLBACK_STYLE_CONSTITUTION = [
+  "1. 叙事优先:每段都要推进剧情、信息或关系,不为氛围而写氛围。",
+  "2. 环境描写克制:只写与当前动作/信息相关的具体实物,一般 1-2 句,禁止连续景物抒情。",
+  "3. 情绪用动作和具体细节呈现,禁止“他感到/她心中/五味杂陈”式总结,点到即止。",
+  "4. 句式以短句和中短句为主,禁止长比喻链、通感堆叠、排比堆字。",
+  "5. 比喻克制:每千字不超过 1-2 处,且用日常化喻体。",
+  "6. 过渡直接(“第二天”“三天后”),不写“时间一分一秒过去”式空转。",
+  "7. 推进要快:能一句带过的时间、路程、重复动作就不展开。",
+  "8. 不点题、不抒情、不在段尾总结,结尾只留钩子。",
+].join("\n")
+
+function truncate(value: string, limit: number): string {
+  if (!value) return ""
+  if (value.length <= limit) return value
+  return `${value.slice(0, limit)}\n\n…(样本过长已截断)…`
+}
+
+function asString(value: unknown): string {
+  return typeof value === "string" ? value.trim() : ""
+}
+
+function asStringArray(value: unknown): string[] {
+  if (!Array.isArray(value)) return []
+  return value.map((item) => asString(item)).filter(Boolean)
+}
+
+/** 剥代码围栏后取最外层 {...};拿不到返回 null。 */
+function extractJsonObject(raw: string): Record<string, unknown> | null {
+  const fenceStripped = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] ?? raw
+  const objectText = fenceStripped.match(/\{[\s\S]*\}/)?.[0]
+  if (!objectText) return null
+  try {
+    return JSON.parse(objectText) as Record<string, unknown>
+  } catch {
+    return null
+  }
+}
+
+const NO_EXTRA_OUTPUT = "只输出一个 JSON 对象,不要解释,不要 markdown 代码围栏。"
+const SPECIFICITY_RULE = "每个字段都必须具体到能直接指导写作,禁止“节奏适中”“描写生动”这类空话。"
+
+// === L1 语言 + L6 排版节奏 ===
+
+/**
+ * 把确定性统计喂进去让模型解读。模型的任务是解释这些数字意味着什么写法,
+ * 而不是重新估算句长和标点频次——那些已经算准了。
+ */
+export function buildLanguageAndRhythmPrompt(
+  metrics: StyleMetrics,
+  sampleText: string,
+  bookTitle: string,
+): string {
+  return [
+    `你是小说文风分析专家。下面给出《${bookTitle}》若干章节的**确定性统计结果**和对应原文样本。`,
+    "统计数字已经由脚本精确算出,你的任务是解读它们对应什么写法,并结合原文验证,不要重新估算这些数字。",
+    "",
+    "=== 脚本统计结果 ===",
+    formatStyleMetricsForPrompt(metrics),
+    "",
+    "=== 原文样本 ===",
+    truncate(sampleText, SAMPLE_TEXT_LIMIT),
+    "",
+    NO_EXTRA_OUTPUT,
+    "字段要求:",
+    "1. `languageDna`(L1 语言 DNA,600-1200 字中文):解读句长分布、标点习惯、高频词与二元搭配、对白口语度。必须引用上面的具体数字,说明这些数字对应作者的什么习惯,并从原文举例印证。",
+    "2. `rhythmGuide`(L6 排版与节奏,400-800 字中文):解读段落长度分布、一句一段的使用时机、长段出现在什么场景、用什么方式分隔场景、章标题格式。同样要引用数字。",
+    "3. `narrativeDensity`:叙事密度与推进速度,3-6 句。",
+    "4. `sentenceStyle`:句式与句长、口语化程度,3-6 句。",
+    "5. `rhetoricDensity`:比喻与通感的密度和喻体取材,3-6 句。",
+    "6. `dialogueStyle`:对白风格(口语度、毛边、潜台词、对白标签用词),3-6 句。",
+    "7. `vocabularyPreferences`:字符串数组,列出稳定的词汇、动词和口语偏好,优先取自上面的高频词表。",
+    "8. `avoidPatterns`:字符串数组,列出仿写这种文风时应避免的写法。",
+    "",
+    "硬性要求:",
+    "- 只分析语言与排版,不要分析剧情走向或人物性格。",
+    `- ${SPECIFICITY_RULE}`,
+    "- 统计结果里标注为近似的词频表,解读时要说明其局限,不要当成精确词表下结论。",
+    "- JSON 之外不要输出任何内容。",
+  ].join("\n")
+}
+
+export function parseLanguageAndRhythmResult(raw: string): LanguageAndRhythmResult {
+  const parsed = extractJsonObject(raw)
+  return {
+    languageDna: asString(parsed?.languageDna),
+    rhythmGuide: asString(parsed?.rhythmGuide),
+    narrativeDensity: asString(parsed?.narrativeDensity),
+    sentenceStyle: asString(parsed?.sentenceStyle),
+    rhetoricDensity: asString(parsed?.rhetoricDensity),
+    dialogueStyle: asString(parsed?.dialogueStyle),
+    vocabularyPreferences: asStringArray(parsed?.vocabularyPreferences),
+    avoidPatterns: asStringArray(parsed?.avoidPatterns),
+  }
+}
+
+// === L2 章节结构 ===
+
+export function buildStructurePatternPrompt(sampleText: string, bookTitle: string): string {
+  return [
+    `你是小说结构分析专家。请阅读《${bookTitle}》的章节原文样本,提炼这本书**每一章是怎么搭起来的**。`,
+    "注意:分析的是可复用的结构骨架,不是复述剧情。",
+    "",
+    NO_EXTRA_OUTPUT,
+    "字段要求:",
+    "1. `structurePatterns`(L2 章节结构模板,800-1500 字中文):按章节类型(如推进章、冲突章、过渡章、爆点章)分别给出可复用骨架,每类写成这种形式:",
+    "   开场 hook 类型 + 大致字数 → 第一个转折点怎么出现 → 正文如何组织场景 → 章尾钩子类型。",
+    "   同时说明:单章通常几个场景、场景之间怎么切、叙述与对白与描写的大致配比。",
+    "2. `transitionStyle`:场景与时间过渡的具体做法,3-6 句。",
+    "3. `descriptionWeight`:环境描写的比重与取材(具体实物 vs 抒情),3-6 句。",
+    "4. `emotionRendering`:情绪呈现方式(动作外显 vs 内心独白、克制度),3-6 句。",
+    "5. `thematicHabits`:点题、总结、抒情的习惯与位置,3-6 句。",
+    "6. `samples`:从上面提供的原文中**原样摘抄** 4~6 段最能体现结构与文风的片段(每段 80~300 字)。必须原文照抄,不得改写或自编。",
+    "7. `evidence`:3~8 个代表片段,每项含 chapterId、text、tags、reason、purpose。chapterId 必须取自样本标题里的章节 ID,text 必须是 80~300 字原文短片段,不得改写。",
+    "8. `chapterMeta`:为样本里**每一章**返回一条标注,每项含:",
+    "   `chapterId`(取自样本标题)、",
+    "   `hookType`(开场 hook 类型,只能取:冲突式 / 场景式 / 对白式 / 悬念式 / 叙述式 / 问题式)、",
+    "   `structurePattern`(章节结构类型,只能取:推进章 / 冲突章 / 过渡章 / 爆点章 / 信息章)、",
+    "   `topicTags`(2-4 个题材标签,用于日后按题材检索相似章节,例如“宗门试炼”“家族斗争”“炼丹”)。",
+    "",
+    "硬性要求:",
+    "- 结构模板必须覆盖至少 2 种章节类型;样本里只看得出 1 种时,明确说明只观察到 1 种。",
+    "- chapterMeta 必须覆盖样本里出现的每一个章节 ID,不得遗漏也不得编造未出现的 ID。",
+    `- ${SPECIFICITY_RULE}`,
+    "- samples 与 evidence 必须来自提供的原文,不得虚构。",
+    "- evidence 的 tags 要标明章尾钩子、场景切换、对白节奏等可检索特征。",
+    "- JSON 之外不要输出任何内容。",
+    "",
+    "=== 原文样本 ===",
+    truncate(sampleText, SAMPLE_TEXT_LIMIT),
+  ].join("\n")
+}
+
+function parseChapterMetaAnnotations(value: unknown): ChapterMetaAnnotation[] {
+  if (!Array.isArray(value)) return []
+  return value.flatMap((item): ChapterMetaAnnotation[] => {
+    if (!item || typeof item !== "object") return []
+    const candidate = item as Record<string, unknown>
+    const chapterId = asString(candidate.chapterId)
+    if (!chapterId) return []
+    return [{
+      chapterId,
+      hookType: asString(candidate.hookType),
+      structurePattern: asString(candidate.structurePattern),
+      topicTags: asStringArray(candidate.topicTags).slice(0, 6),
+    }]
+  })
+}
+
+export function parseStructurePatternResult(raw: string): StructurePatternResult {
+  const parsed = extractJsonObject(raw)
+  return {
+    structurePatterns: asString(parsed?.structurePatterns),
+    transitionStyle: asString(parsed?.transitionStyle),
+    descriptionWeight: asString(parsed?.descriptionWeight),
+    emotionRendering: asString(parsed?.emotionRendering),
+    thematicHabits: asString(parsed?.thematicHabits),
+    samples: asStringArray(parsed?.samples).slice(0, 6),
+    chapterMeta: parseChapterMetaAnnotations(parsed?.chapterMeta),
+  }
+}
+
+// === L3-L5 叙事视角与认知框架 ===
+
+export function buildCognitiveFramePrompt(sampleText: string, bookTitle: string): string {
+  return [
+    `你是小说叙事分析专家。请阅读《${bookTitle}》的章节原文样本,提炼作者**怎么想**——`,
+    "也就是他在推进情节时的取舍标准、细节素材的取材习惯、以及反复出现的价值判断。",
+    "",
+    NO_EXTRA_OUTPUT,
+    "字段要求:",
+    "1. `cognitiveFrame`(L3-L5 合并,1000-1800 字中文),必须包含以下三个小节,用 markdown 三级标题分隔:",
+    "   `### 推进取舍(L3)`:什么时候进冲突、什么详写什么一句带过、哪类场景作者根本不写、爽点和压抑段的间隔节奏。",
+    "   `### 细节素材(L4)`:偏好哪些感官通道、专业术语与数字的使用密度、生活细节从哪里取材、如何处理设定信息的投放。",
+    "   `### 价值判断(L5)`:作者反复出现的核心命题(至少 3 条,且必须是不显而易见的)、他认为什么算“爽”、什么样的人物行为被默认为正当。",
+    "2. `narrativeVoice`:叙述视角与叙述者声音,3-6 句。",
+    "3. `pointOfView`:明确叙事人称与限知范围。",
+    "4. `humorMechanisms`:字符串数组,说明幽默如何产生(如反差、一本正经地补刀、误解递进);无明显幽默时返回空数组。",
+    "5. `highEnergyMechanisms`:字符串数组,说明高燃段落如何抬压、加速和兑现;无明显特征时返回空数组。",
+    "",
+    "硬性要求:",
+    "- 核心命题必须从原文归纳,不能套用通用网文常识。",
+    "- 不要复述剧情,不要评价作品好坏。",
+    `- ${SPECIFICITY_RULE}`,
+    "- JSON 之外不要输出任何内容。",
+    "",
+    "=== 原文样本 ===",
+    truncate(sampleText, SAMPLE_TEXT_LIMIT),
+  ].join("\n")
+}
+
+export function parseCognitiveFrameResult(raw: string): CognitiveFrameResult {
+  const parsed = extractJsonObject(raw)
+  return {
+    cognitiveFrame: asString(parsed?.cognitiveFrame),
+    narrativeVoice: asString(parsed?.narrativeVoice),
+    pointOfView: asString(parsed?.pointOfView),
+    humorMechanisms: asStringArray(parsed?.humorMechanisms),
+    highEnergyMechanisms: asStringArray(parsed?.highEnergyMechanisms),
+  }
+}
+
+// === 整合:Writing-DNA.md ===
+
+/**
+ * 整合步输出 markdown 而不是 JSON:整合文档有数千字并且含 markdown 结构,
+ * 走 JSON 字符串转义时被截断或转义错误的概率太高。
+ */
+export function buildDnaIntegrationPrompt(
+  layers: WritingDnaLayers,
+  metrics: StyleMetrics,
+  bookTitle: string,
+): string {
+  return [
+    `你是文风蒸馏专家。下面是《${bookTitle}》已完成的分层分析结果和脚本统计。`,
+    `请把它们整合成一份可直接喂给 AI 复刻文风的总纲,**总字数不超过 ${INTEGRATED_DNA_CHAR_LIMIT} 字**。`,
+    "超出上限说明没蒸馏干净——要压掉重复和形容词,保留可执行的规则和具体数字。",
+    "",
+    "直接输出 markdown 正文,不要 JSON,不要代码围栏,不要任何前言后语。必须包含以下小节:",
+    "",
+    "## 语言特征",
+    "句长与短句比例的目标区间、标点习惯、必用与禁用词汇。要带上具体数字。",
+    "",
+    "## 章节结构",
+    "按章节类型给出骨架,每类不超过 6 行。",
+    "",
+    "## 推进与素材取舍",
+    "什么详写什么略写、细节从哪些感官通道取。",
+    "",
+    "## 核心认知框架",
+    "作者反复出现的核心命题,不超过 5 条。",
+    "",
+    "## 排版与节奏",
+    "段落长度目标、一句一段的使用时机、场景分隔方式。",
+    "",
+    `## ${CONSTITUTION_SECTION_TITLE}`,
+    "8~12 条编号硬约束,每条一行,格式为「数字. 约束内容,因为作者的理由」。",
+    "每条必须是可直接执行的写作指令(写什么/不写什么、多少字以内),不能是空泛原则。",
+    "这一节会被单独抽出来注入生成,必须自洽可独立阅读。",
+    "",
+    "=== 脚本统计 ===",
+    formatStyleMetricsForPrompt(metrics),
+    "",
+    "=== L1 语言 DNA ===",
+    layers.languageDna || "(未提取)",
+    "",
+    "=== L2 章节结构模板 ===",
+    layers.structurePatterns || "(未提取)",
+    "",
+    "=== L3-L5 叙事视角与认知框架 ===",
+    layers.cognitiveFrame || "(未提取)",
+    "",
+    "=== L6 排版与节奏 ===",
+    layers.rhythmGuide || "(未提取)",
+  ].join("\n")
+}
+
+/**
+ * 解析整合结果:整篇 markdown 作为 integratedDna,
+ * 再把「风格硬约束」小节单独抽出来作为 constitution(注入降级时用)。
+ */
+export function parseIntegratedDnaResult(raw: string): IntegratedDnaResult {
+  const fenceStripped = raw.match(/```(?:markdown|md)?\s*([\s\S]*?)```/i)?.[1] ?? raw
+  const integratedDna = fenceStripped.trim()
+  if (!integratedDna) {
+    return { integratedDna: "", constitution: FALLBACK_STYLE_CONSTITUTION }
+  }
+
+  // 结尾用 (?![\s\S]) 表示"输入结束":m 标志下的 $ 只到行尾,会把小节切成第一行
+  const sectionPattern = new RegExp(
+    `^#{1,4}\\s*${CONSTITUTION_SECTION_TITLE}[^\\n]*\\n([\\s\\S]*?)(?=\\n#{1,4}\\s|(?![\\s\\S]))`,
+    "m",
+  )
+  const section = integratedDna.match(sectionPattern)?.[1]?.trim() ?? ""
+  // 没有小节时兜底:抓连续的编号行,仍抓不到才用通用宪法
+  const numberedLines = section
+    ? section
+    : (integratedDna.match(/^\s*\d+[.、][^\n]+$/gm) ?? []).join("\n").trim()
+
+  return {
+    integratedDna,
+    constitution: numberedLines || FALLBACK_STYLE_CONSTITUTION,
+  }
+}
+
+/**
+ * 从整合文档里摘掉「风格硬约束」小节。
+ * 注入时硬约束单独放在最前面保证不被截断,正文再跟上,避免同一段内容注入两遍。
+ */
+export function stripConstitutionSection(integratedDna: string): string {
+  const pattern = new RegExp(
+    `^#{1,4}\\s*${CONSTITUTION_SECTION_TITLE}[^\\n]*\\n[\\s\\S]*?(?=\\n#{1,4}\\s|(?![\\s\\S]))`,
+    "m",
+  )
+  return integratedDna.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trim()
+}
+
+// === 证据片段(L2 调用附带返回)===
+
+export function parseStyleEvidenceResult(raw: string): StyleEvidenceCandidate[] {
+  const parsed = extractJsonObject(raw)
+  if (!parsed || !Array.isArray(parsed.evidence)) return []
+  return parsed.evidence.flatMap((item): StyleEvidenceCandidate[] => {
+    if (!item || typeof item !== "object") return []
+    const candidate = item as Record<string, unknown>
+    const chapterId = asString(candidate.chapterId)
+    const text = asString(candidate.text)
+    if (!chapterId || !text) return []
+    return [{
+      chapterId,
+      text,
+      tags: asStringArray(candidate.tags),
+      reason: asString(candidate.reason),
+      purpose: asString(candidate.purpose),
+    }]
+  }).slice(0, 8)
+}
+
+// === 多分片汇总 ===
+
+/** 把多个分片的同一层文本交给模型去重合并(分片数 > 1 时才调用)。 */
+export function buildLayerAggregatePrompt(
+  layerLabel: string,
+  chunkTexts: string[],
+  bookTitle: string,
+): string {
+  return [
+    `你是文风汇总专家。以下是《${bookTitle}》不同章节区块各自得出的「${layerLabel}」分析结果。`,
+    "请合并成一份:去掉重复、剔除只在单个区块出现的偶发特征、保留所有具体数字和可执行规则。",
+    "直接输出合并后的正文,保持与输入相同的语言和 markdown 结构,不要 JSON,不要代码围栏,不要前言。",
+    "不得引入未分析章节的内容,不得复述剧情。",
+    "",
+    ...chunkTexts.map((text, index) => `=== 区块 ${index + 1} ===\n${text}`),
+  ].join("\n\n")
+}
+
+export function parseLayerAggregateResult(raw: string): string {
+  const fenceStripped = raw.match(/```(?:markdown|md)?\s*([\s\S]*?)```/i)?.[1] ?? raw
+  return fenceStripped.trim()
+}

+ 146 - 0
src/lib/novel/book-analysis/writing-dna-render.spec.ts

@@ -0,0 +1,146 @@
+import { describe, expect, it } from "vitest"
+import type { ChapterMetaEntry } from "./chapter-meta"
+import { computeStyleMetrics } from "./style-metrics"
+import { INTEGRATED_DNA_CHAR_LIMIT, WRITING_DNA_LAYERS } from "./style-profile-schema"
+import type { BookStyleProfile } from "./types"
+import { renderIntegratedDna, renderWritingDnaFiles, styleProfileToMarkdown } from "./writing-dna-render"
+
+function profile(overrides: Partial<BookStyleProfile> = {}): BookStyleProfile {
+  return {
+    schemaVersion: 2,
+    generatedAt: 1,
+    sampledChapterIds: ["ch-0001", "ch-0002"],
+    metrics: computeStyleMetrics(["他走了。她没动。\n\n“别过来。”"]),
+    layers: {
+      languageDna: "短句为主。",
+      structurePatterns: "推进章骨架。",
+      cognitiveFrame: "能一句带过就不展开。",
+      rhythmGuide: "一句一段用于转折。",
+    },
+    integratedDna: "## 语言特征\n短句为主。",
+    constitution: "1. 环境描写不超过 2 句。",
+    samples: ["片段一"],
+    transitionStyle: "直接跳时间",
+    narrativeVoice: "冷静叙述者",
+    humorMechanisms: ["补刀"],
+    vocabularyPreferences: ["回头"],
+    avoidPatterns: ["长比喻链"],
+    ...overrides,
+  }
+}
+
+const chapterMeta: ChapterMetaEntry[] = [{
+  chapterId: "ch-0001", order: 1, title: "试炼", wordCount: 900,
+  hookType: "冲突式", structurePattern: "冲突章", sceneCount: 2,
+  topicTags: ["宗门试炼"], dialogueRatio: 0.4, updatedAt: 1,
+}]
+
+describe("renderWritingDnaFiles", () => {
+  it("产出整合文档与四份分层产物", () => {
+    const files = renderWritingDnaFiles(profile(), "凡人修仙传", chapterMeta)
+    expect(files.map((file) => file.fileName)).toEqual([
+      "Writing-DNA.md",
+      ...WRITING_DNA_LAYERS.map((layer) => layer.fileName),
+    ])
+  })
+
+  it("语言DNA.md 含统计表、词汇偏好与禁用写法", () => {
+    const content = renderWritingDnaFiles(profile(), "书", chapterMeta)
+      .find((file) => file.fileName === "语言DNA.md")!.content
+    expect(content).toContain("短句为主。")
+    expect(content).toContain("| 指标 | 数值 |")
+    expect(content).toContain("平均句长")
+    expect(content).toContain("回头")
+    expect(content).toContain("长比喻链")
+  })
+
+  it("章节结构模板.md 含分项说明与章节标注表", () => {
+    const content = renderWritingDnaFiles(profile(), "书", chapterMeta)
+      .find((file) => file.fileName === "章节结构模板.md")!.content
+    expect(content).toContain("推进章骨架。")
+    expect(content).toContain("直接跳时间")
+    expect(content).toContain("ch-0001")
+    expect(content).toContain("宗门试炼")
+  })
+
+  it("认知框架文件含视角说明与幽默机制", () => {
+    const content = renderWritingDnaFiles(profile(), "书", chapterMeta)
+      .find((file) => file.fileName === "叙事视角与认知框架.md")!.content
+    expect(content).toContain("能一句带过就不展开。")
+    expect(content).toContain("冷静叙述者")
+    expect(content).toContain("补刀")
+  })
+
+  it("排版节奏指南含节奏统计", () => {
+    const content = renderWritingDnaFiles(profile(), "书", chapterMeta)
+      .find((file) => file.fileName === "排版节奏指南.md")!.content
+    expect(content).toContain("一句一段用于转折。")
+    expect(content).toContain("一句一段占比")
+  })
+
+  it("某层缺失时写占位提示而不是空文件", () => {
+    const content = renderWritingDnaFiles(
+      profile({ layers: { languageDna: "", structurePatterns: "", cognitiveFrame: "", rhythmGuide: "" } }),
+      "书",
+    ).find((file) => file.fileName === "语言DNA.md")!.content
+    expect(content).toContain("请重新运行文风提取")
+  })
+
+  it("没有章节标注时给出占位而不是空表", () => {
+    const content = renderWritingDnaFiles(profile(), "书")
+      .find((file) => file.fileName === "章节结构模板.md")!.content
+    expect(content).toContain("(无章节标注)")
+  })
+
+  it("统计缺失时不抛错", () => {
+    const files = renderWritingDnaFiles(profile({ metrics: undefined }), "书")
+    expect(files.find((file) => file.fileName === "语言DNA.md")!.content).toContain("(无统计数据)")
+    expect(files.find((file) => file.fileName === "排版节奏指南.md")!.content).toContain("(无统计数据)")
+  })
+})
+
+describe("renderIntegratedDna", () => {
+  it("带文件头、样本数与整合正文", () => {
+    const content = renderIntegratedDna(profile(), "凡人修仙传")
+    expect(content).toContain("《凡人修仙传》Writing DNA")
+    expect(content).toContain("2 章样本")
+    expect(content).toContain("## 语言特征")
+    expect(content).toContain("片段一")
+  })
+
+  it("超过字数上限时提醒重新提取", () => {
+    const content = renderIntegratedDna(
+      profile({ integratedDna: "字".repeat(INTEGRATED_DNA_CHAR_LIMIT + 1) }),
+      "书",
+    )
+    expect(content).toContain(`超过 ${INTEGRATED_DNA_CHAR_LIMIT} 字上限`)
+  })
+
+  it("没有整合文档时降级展示硬约束", () => {
+    const content = renderIntegratedDna(profile({ integratedDna: "" }), "书")
+    expect(content).toContain("未生成整合文档")
+    expect(content).toContain("环境描写不超过 2 句")
+  })
+})
+
+describe("styleProfileToMarkdown", () => {
+  it("style.md 含整合正文与分层产物索引", () => {
+    const content = styleProfileToMarkdown(profile(), "书")
+    expect(content).toContain("## 语言特征")
+    expect(content).toContain("## 分层产物")
+    for (const layer of WRITING_DNA_LAYERS) {
+      expect(content).toContain(`./${layer.fileName}`)
+    }
+  })
+
+  it("v1 迁移产物提示重新提取", () => {
+    const content = styleProfileToMarkdown(
+      profile({
+        integratedDna: "",
+        layers: { languageDna: "", structurePatterns: "", cognitiveFrame: "", rhythmGuide: "" },
+      }),
+      "书",
+    )
+    expect(content).toContain("建议重新提取文风")
+  })
+})

+ 189 - 0
src/lib/novel/book-analysis/writing-dna-render.ts

@@ -0,0 +1,189 @@
+/**
+ * Writing DNA 分层产物的 markdown 渲染(feature/writing-dna)
+ *
+ * 取代原 style-extraction-engine 里的 styleProfileToMarkdown。
+ * 每一层单独出一个文件,整合文档单独出 Writing-DNA.md:
+ * 分层产物保留具体语感,整合文档是压缩后的结论——两者都要留,不能只留整合的那份。
+ */
+import type { ChapterMetaEntry } from "./chapter-meta"
+import { formatChapterMetaForMarkdown } from "./chapter-meta"
+import { formatStyleMetricsForMarkdown } from "./style-metrics"
+import {
+  INTEGRATED_DNA_CHAR_LIMIT,
+  WRITING_DNA_LAYERS,
+  isWritingDnaProfile,
+} from "./style-profile-schema"
+import type { BookStyleProfile, WritingDnaLayers } from "./types"
+
+export interface RenderedDnaFile {
+  fileName: string
+  content: string
+}
+
+function header(bookTitle: string, level: string, label: string, chapterCount: number): string[] {
+  return [
+    `# 《${bookTitle}》${label}(${level})`,
+    "",
+    `> 由 Writing DNA 分层蒸馏生成 · ${chapterCount} 章样本`,
+    "",
+  ]
+}
+
+function bodyOrPlaceholder(text: string): string {
+  return text.trim() || "(本层未提取,请重新运行文风提取)"
+}
+
+/** L1 语言 DNA:模型解读 + 脚本统计表。统计表必须留在文件里,写作前复读时要能看到数字。 */
+function renderLanguageDna(profile: BookStyleProfile, bookTitle: string): string {
+  const lines = header(bookTitle, "L1", "语言 DNA", profile.sampledChapterIds.length)
+  lines.push(bodyOrPlaceholder(profile.layers?.languageDna ?? ""), "", "## 脚本统计", "")
+  lines.push(profile.metrics ? formatStyleMetricsForMarkdown(profile.metrics) : "(无统计数据)")
+  if (profile.vocabularyPreferences?.length) {
+    lines.push("", "## 词汇偏好", "", profile.vocabularyPreferences.map((item) => `- ${item}`).join("\n"))
+  }
+  if (profile.avoidPatterns?.length) {
+    lines.push("", "## 应避免的写法", "", profile.avoidPatterns.map((item) => `- ${item}`).join("\n"))
+  }
+  return lines.join("\n")
+}
+
+/** L2 章节结构模板 + 每章标注表。 */
+function renderStructurePatterns(
+  profile: BookStyleProfile,
+  bookTitle: string,
+  chapterMeta: ChapterMetaEntry[],
+): string {
+  const lines = header(bookTitle, "L2", "章节结构模板", profile.sampledChapterIds.length)
+  lines.push(bodyOrPlaceholder(profile.layers?.structurePatterns ?? ""))
+  const extras: Array<[string, string | undefined]> = [
+    ["场景与时间过渡", profile.transitionStyle],
+    ["环境描写比重", profile.descriptionWeight],
+    ["情绪呈现", profile.emotionRendering],
+    ["点题与抒情习惯", profile.thematicHabits],
+  ]
+  const filled = extras.filter(([, value]) => value?.trim())
+  if (filled.length > 0) {
+    lines.push("", "## 分项说明", "")
+    for (const [label, value] of filled) lines.push(`- **${label}**:${value}`)
+  }
+  lines.push("", "## 章节标注", "", formatChapterMetaForMarkdown(chapterMeta))
+  return lines.join("\n")
+}
+
+/** L3-L5 叙事视角与认知框架。 */
+function renderCognitiveFrame(profile: BookStyleProfile, bookTitle: string): string {
+  const lines = header(bookTitle, "L3-L5", "叙事视角与认知框架", profile.sampledChapterIds.length)
+  lines.push(bodyOrPlaceholder(profile.layers?.cognitiveFrame ?? ""))
+  const extras: Array<[string, string | undefined]> = [
+    ["叙述视角与声音", profile.narrativeVoice],
+    ["叙事人称与限知范围", profile.pointOfView],
+  ]
+  const filled = extras.filter(([, value]) => value?.trim())
+  if (filled.length > 0) {
+    lines.push("", "## 分项说明", "")
+    for (const [label, value] of filled) lines.push(`- **${label}**:${value}`)
+  }
+  if (profile.humorMechanisms?.length) {
+    lines.push("", "## 幽默机制", "", profile.humorMechanisms.map((item) => `- ${item}`).join("\n"))
+  }
+  if (profile.highEnergyMechanisms?.length) {
+    lines.push("", "## 高燃机制", "", profile.highEnergyMechanisms.map((item) => `- ${item}`).join("\n"))
+  }
+  return lines.join("\n")
+}
+
+/** L6 排版与节奏。 */
+function renderRhythmGuide(profile: BookStyleProfile, bookTitle: string): string {
+  const lines = header(bookTitle, "L6", "排版与节奏指南", profile.sampledChapterIds.length)
+  lines.push(bodyOrPlaceholder(profile.layers?.rhythmGuide ?? ""), "", "## 节奏相关统计", "")
+  if (profile.metrics) {
+    const { derived } = profile.metrics
+    const percent = (value: number): string => `${(value * 100).toFixed(1)}%`
+    lines.push(
+      `- 平均段落:${derived.avgParagraphChars} 字 / ${derived.avgParagraphSentences} 句`,
+      `- 一句一段占比:${percent(derived.oneSentenceParagraphRatio)}`,
+      `- 长段占比:${percent(derived.longParagraphRatio)}`,
+      `- 含对白段落占比:${percent(derived.dialogueParagraphRatio)}`,
+      `- 破折号每千字 ${derived.punctuationPerThousand.dash}、省略号每千字 ${derived.punctuationPerThousand.ellipsis}`,
+    )
+  } else {
+    lines.push("(无统计数据)")
+  }
+  return lines.join("\n")
+}
+
+/**
+ * 整合文档 Writing-DNA.md。
+ * 模型已按要求输出 markdown 正文,这里只补文件头、代表片段和字数超限提醒。
+ */
+export function renderIntegratedDna(
+  profile: BookStyleProfile,
+  bookTitle: string,
+): string {
+  const body = profile.integratedDna?.trim()
+  const lines = [
+    `# 《${bookTitle}》Writing DNA`,
+    "",
+    `> 分层蒸馏整合文档 · ${profile.sampledChapterIds.length} 章样本 · 写作前需与四份分层产物一起复读`,
+    "",
+  ]
+  if (body) {
+    lines.push(body)
+    if (body.length > INTEGRATED_DNA_CHAR_LIMIT) {
+      lines.push(
+        "",
+        `> 注意:本文档 ${body.length} 字,超过 ${INTEGRATED_DNA_CHAR_LIMIT} 字上限,说明蒸馏不够干净,建议重新提取。`,
+      )
+    }
+  } else {
+    lines.push(
+      "(未生成整合文档,以下为可注入的风格硬约束)",
+      "",
+      "## 风格硬约束",
+      "",
+      profile.constitution || "(无)",
+    )
+  }
+  if (profile.samples.length > 0) {
+    lines.push("", "## 代表原文片段", "")
+    profile.samples.forEach((sample, index) => lines.push(`${index + 1}. ${sample}`, ""))
+  }
+  return lines.join("\n")
+}
+
+/** 渲染全部产物文件(Writing-DNA.md + 四份分层产物)。 */
+export function renderWritingDnaFiles(
+  profile: BookStyleProfile,
+  bookTitle: string,
+  chapterMeta: ChapterMetaEntry[] = [],
+): RenderedDnaFile[] {
+  const byKey: Record<keyof WritingDnaLayers, string> = {
+    languageDna: renderLanguageDna(profile, bookTitle),
+    structurePatterns: renderStructurePatterns(profile, bookTitle, chapterMeta),
+    cognitiveFrame: renderCognitiveFrame(profile, bookTitle),
+    rhythmGuide: renderRhythmGuide(profile, bookTitle),
+  }
+  return [
+    { fileName: "Writing-DNA.md", content: renderIntegratedDna(profile, bookTitle) },
+    ...WRITING_DNA_LAYERS.map((layer) => ({
+      fileName: layer.fileName,
+      content: byKey[layer.key],
+    })),
+  ]
+}
+
+/**
+ * style.md 的内容。
+ * 这个文件名被 library-state 的 styleStatus 判定和删除逻辑依赖,不能取消,
+ * 因此保留为整合文档的同文副本 + 分层产物索引。
+ */
+export function styleProfileToMarkdown(profile: BookStyleProfile, bookTitle: string): string {
+  const lines = [renderIntegratedDna(profile, bookTitle), "", "## 分层产物", ""]
+  for (const layer of WRITING_DNA_LAYERS) {
+    lines.push(`- [${layer.level} ${layer.label}](./${layer.fileName}):${layer.summary}`)
+  }
+  if (!isWritingDnaProfile(profile)) {
+    lines.push("", "> 本画像由旧版单轮提取迁移而来,尚无分层产物,建议重新提取文风。")
+  }
+  return lines.join("\n")
+}

+ 2 - 2
src/lib/novel/context-data-sources.ts

@@ -354,9 +354,9 @@ const writingStyleDataSource: DataSource<string> = {
   name: "writingStyle",
   priority: 12,
   async load(context: ContextLoadContext): Promise<string> {
-    // 优先级1: 已启用的拆书作品文风预设
+    // 优先级1: 已启用的拆书作品文风预设(传 task 以按题材检索最接近的原文片段)
     try {
-      const enabledStyle = await buildWritingStyleContext(context.projectPath)
+      const enabledStyle = await buildWritingStyleContext(context.projectPath, { task: context.task })
       if (enabledStyle.trim()) return enabledStyle
     } catch {}
 

+ 126 - 0
src/lib/novel/writing-style-store.spec.ts

@@ -25,6 +25,7 @@ import {
 
 const PROJECT = "E:/Novel"
 
+/** v1 形状的旧画像:没有 layers / metrics / integratedDna。 */
 function makeProfile(overrides: Partial<BookStyleProfile> = {}): BookStyleProfile {
   return {
     schemaVersion: 1,
@@ -45,6 +46,27 @@ function makeProfile(overrides: Partial<BookStyleProfile> = {}): BookStyleProfil
   }
 }
 
+/** v2 形状:含分层产物与整合文档。 */
+function makeDnaProfile(overrides: Partial<BookStyleProfile> = {}): BookStyleProfile {
+  return makeProfile({
+    schemaVersion: 2,
+    layers: {
+      languageDna: "短句为主。",
+      structurePatterns: "推进章骨架。",
+      cognitiveFrame: "能一句带过就不展开。",
+      rhythmGuide: "一句一段用于转折。",
+    },
+    integratedDna: [
+      "## 语言特征",
+      "短句为主,平均句长 12 字。",
+      "",
+      "## 风格硬约束",
+      "1. 环境描写不超过 2 句。",
+    ].join("\n"),
+    ...overrides,
+  })
+}
+
 beforeEach(() => {
   mem.clear()
 })
@@ -143,3 +165,107 @@ describe("writing-style-store", () => {
     expect(context).not.toContain("原文片段一")
   })
 })
+
+describe("writing-style-store · Writing DNA 注入", () => {
+  async function enable(profile: BookStyleProfile, extra: Record<string, unknown> = {}) {
+    const preset = await upsertWritingStylePreset(PROJECT, {
+      name: "x",
+      sourceBook: "凡人修仙传",
+      profile,
+      ...extra,
+    })
+    await setEnabledWritingStyle(PROJECT, preset.id)
+    return preset
+  }
+
+  it("硬约束排在整合文档正文之前,保证不被挤掉", async () => {
+    await enable(makeDnaProfile())
+    const ctx = await buildWritingStyleContext(PROJECT)
+
+    expect(ctx.indexOf("风格硬约束")).toBeGreaterThan(-1)
+    expect(ctx.indexOf("风格硬约束")).toBeLessThan(ctx.indexOf("Writing DNA 要点"))
+  })
+
+  it("整合文档注入时摘掉硬约束小节,避免同一段内容注入两遍", async () => {
+    await enable(makeDnaProfile())
+    const ctx = await buildWritingStyleContext(PROJECT)
+
+    expect(ctx).toContain("平均句长 12 字")
+    // 「环境描写不超过 2 句」只应来自 constitution 字段,不应随正文再来一次
+    expect(ctx.split("环境描写不超过 2 句").length - 1).toBeLessThanOrEqual(1)
+  })
+
+  it("v1 旧画像没有整合文档时降级为只注入硬约束", async () => {
+    await enable(makeProfile())
+    const ctx = await buildWritingStyleContext(PROJECT)
+
+    expect(ctx).toContain("风格硬约束")
+    expect(ctx).toContain("朴素")
+    expect(ctx).not.toContain("Writing DNA 要点")
+  })
+
+  it("v1 旧画像读出时迁移为 v2 形状并按层归类 9 维内容", async () => {
+    await enable(makeProfile())
+    const preset = await getEnabledWritingStyle(PROJECT)
+
+    expect(preset?.profile.schemaVersion).toBe(2)
+    expect(preset?.profile.layers?.languageDna).toContain("密度高、推进快")
+    expect(preset?.profile.layers?.rhythmGuide).toBe("")
+  })
+
+  it("给了 task 时按题材从 chapter-meta 检索最接近的原文片段", async () => {
+    mem.set(`${PROJECT}/book-analysis/book-1/analysis/chapter-meta.json`, JSON.stringify({
+      version: 1,
+      bookId: "book-1",
+      updatedAt: 1,
+      entries: [
+        {
+          chapterId: "ch-0001", order: 1, title: "宗门试炼", wordCount: 900,
+          hookType: "冲突式", structurePattern: "冲突章", sceneCount: 2,
+          topicTags: ["宗门试炼"], dialogueRatio: 0.4, updatedAt: 1,
+        },
+        {
+          chapterId: "ch-0002", order: 2, title: "赶路", wordCount: 900,
+          hookType: "场景式", structurePattern: "过渡章", sceneCount: 1,
+          topicTags: ["赶路"], dialogueRatio: 0.1, updatedAt: 1,
+        },
+      ],
+    }))
+    mem.set(`${PROJECT}/book-analysis/book-1/chapters/ch-0001.md`, "---\nid: ch-0001\n---\n试炼场上血流成河。")
+    mem.set(`${PROJECT}/book-analysis/book-1/chapters/ch-0002.md`, "---\nid: ch-0002\n---\n他们走了三天。")
+
+    await enable(makeDnaProfile(), { sourceBookId: "book-1" })
+    const ctx = await buildWritingStyleContext(PROJECT, { task: "写一场宗门试炼的对决", relevantChapterCount: 1 })
+
+    expect(ctx).toContain("题材最接近的原文片段")
+    expect(ctx).toContain("试炼场上血流成河")
+    expect(ctx).not.toContain("他们走了三天")
+  })
+
+  it("chapter-meta 缺失时回落到 profile.samples", async () => {
+    await enable(makeDnaProfile(), { sourceBookId: "book-1" })
+    const ctx = await buildWritingStyleContext(PROJECT, { task: "写一场对决" })
+
+    expect(ctx).toContain("原文片段一")
+    expect(ctx).not.toContain("题材最接近的原文片段")
+  })
+
+  it("没有 task 时不做检索,直接用已有样本", async () => {
+    await enable(makeDnaProfile(), { sourceBookId: "book-1" })
+    const ctx = await buildWritingStyleContext(PROJECT)
+
+    expect(ctx).toContain("原文片段一")
+  })
+
+  it("整合文档正文超预算时被截断,但硬约束完整保留", async () => {
+    await enable(makeDnaProfile({
+      constitution: "1. 环境描写不超过 2 句。",
+      integratedDna: `## 语言特征\n${"细节".repeat(2000)}\n\n## 风格硬约束\n1. 环境描写不超过 2 句。`,
+    }))
+    const ctx = await buildWritingStyleContext(PROJECT)
+
+    expect(ctx).toContain("环境描写不超过 2 句")
+    expect(ctx).toContain("…")
+    expect(ctx.length).toBeLessThan(6000)
+  })
+})

+ 106 - 13
src/lib/novel/writing-style-store.ts

@@ -1,15 +1,20 @@
 /**
- * 作品文风预设的项目级启用态 + 生成注入(feature/book-style-extraction)
+ * 作品文风预设的项目级启用态 + 生成注入(feature/book-style-extraction → feature/writing-dna)
  *
  * 镜像 character-aura 的 store 范式:存盘在 <projectPath>/.qmai/writing-style.json。
- * 启用某个文风后,buildWritingStyleContext() 把"风格宪法 + 代表样本"拼成注入文本,
- * 由 context-engine 的 readWritingStyle 接入 contextPack.writingStyle,
+ * 启用某个文风后,buildWritingStyleContext() 把"整合 DNA + 代表片段"拼成注入文本,
+ * 由 context-engine 接入 contextPack.writingStyle,
  * 经 contextPackToPrompt 流向普通对话与深度生成各阶段(含缓存前缀)。
  *
- * 红线:只注入蒸馏后的宪法 + 少量短样本,绝不注入整本原文。
+ * 对齐 writing-dna-skill 第六节「每次写作前必读」:注入的不只是压缩后的整合文档,
+ * 还要按当次写作任务从 chapter-meta 里检索体裁题材最接近的原文片段——
+ * 具体语感(句子呼吸、段落怎么接)只存在于原文里,整合文档描述不出来。
+ *
+ * 红线:只注入蒸馏结果 + 少量短片段,绝不注入整本原文。
  */
 import { createDirectory, readFile, writeFileAtomic } from "@/commands/fs"
-import { normalizePath } from "@/lib/path-utils"
+import { joinPath, normalizePath } from "@/lib/path-utils"
+import { migrateStyleProfile } from "./book-analysis/style-profile-schema"
 import type { BookStyleProfile } from "./book-analysis/types"
 
 export interface WritingStylePreset {
@@ -33,10 +38,24 @@ interface BuildWritingStyleContextOptions {
   includeSamples?: boolean
   constitutionCharLimit?: number
   samplesCharLimit?: number
+  /** 本次写作任务描述;用于按体裁题材检索最接近的原文片段。 */
+  task?: string
+  /** 检索原文的条数,对齐 writing-dna-skill 的「读 5 篇」。 */
+  relevantChapterCount?: number
 }
 
 const DEFAULT_CONSTITUTION_LIMIT = 800
 const DEFAULT_SAMPLES_LIMIT = 2500
+/**
+ * 整合文档正文的注入预算(已摘掉硬约束小节,硬约束单独注入)。
+ * 刻意压得比落盘上限 INTEGRATED_DNA_CHAR_LIMIT 紧:writingStyle 在 trimContextPack 里
+ * 排在第 19 位,是整段丢弃而不是逐字截断,payload 越大越容易被整段丢掉。
+ * 完整文档仍在 Writing-DNA.md 里,供人和 agent 直接读。
+ */
+const DEFAULT_DNA_BODY_LIMIT = 1800
+const DEFAULT_RELEVANT_CHAPTER_COUNT = 5
+/** 每条检索片段的长度上限:只用来校准语感,不是给素材。 */
+const RETRIEVED_EXCERPT_CHARS = 300
 
 function storePath(projectPath: string): string {
   return `${normalizePath(projectPath)}/.qmai/writing-style.json`
@@ -119,7 +138,10 @@ export async function setEnabledWritingStyle(projectPath: string, styleId: strin
 export async function getEnabledWritingStyle(projectPath: string): Promise<WritingStylePreset | null> {
   const store = await loadWritingStyleStore(projectPath)
   if (!store.enabledStyleId) return null
-  return store.styles.find((s) => s.id === store.enabledStyleId) ?? null
+  const preset = store.styles.find((s) => s.id === store.enabledStyleId)
+  if (!preset) return null
+  // 盘上可能是 v1 数据,统一迁移成 v2 形状再交给调用方
+  return { ...preset, profile: migrateStyleProfile(preset.profile) }
 }
 
 function clip(value: string, limit: number): string {
@@ -128,9 +150,48 @@ function clip(value: string, limit: number): string {
   return `${trimmed.slice(0, limit)}…`
 }
 
+function bookPathFor(projectPath: string, bookId: string): string {
+  return joinPath(normalizePath(projectPath), "book-analysis", bookId)
+}
+
+/**
+ * 按本次写作任务检索题材最接近的原文片段。
+ * 拿不到 chapter-meta(旧数据或 fast 模式没标注)时返回空数组,由调用方回落到已有样本。
+ */
+async function retrieveRelevantExcerpts(
+  projectPath: string,
+  bookId: string,
+  task: string,
+  limit: number,
+): Promise<string[]> {
+  const bookPath = bookPathFor(projectPath, bookId)
+  const [{ loadChapterMeta, selectRelevantChapters, excerptChapterBody }] = await Promise.all([
+    import("./book-analysis/chapter-meta"),
+  ])
+  const collection = await loadChapterMeta(bookPath)
+  if (collection.entries.length === 0) return []
+  const picked = selectRelevantChapters(collection.entries, task, limit)
+
+  const excerpts: string[] = []
+  for (const entry of picked) {
+    try {
+      const raw = await readFile(joinPath(bookPath, "chapters", `${entry.chapterId}.md`))
+      const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim()
+      const excerpt = excerptChapterBody(body, RETRIEVED_EXCERPT_CHARS)
+      if (excerpt) excerpts.push(excerpt)
+    } catch {
+      // 章节文件缺失就跳过,不因为一章读不到而放弃整次注入
+    }
+  }
+  return excerpts
+}
+
 /**
  * 把当前启用的文风预设拼成注入文本(无启用项返回 "")。
- * 内含硬约束:只学文风、不借样本中的人物/地名/设定/情节。
+ *
+ * 内容优先级:整合 Writing DNA > 风格硬约束(v1 数据或整合缺失时降级)。
+ * 参考片段优先级:按任务检索的原文 > 已启用 evidence > profile.samples。
+ * 内含红线:只学写法、不借片段里的人物/地名/设定/情节。
  */
 export async function buildWritingStyleContext(
   projectPath: string,
@@ -142,26 +203,58 @@ export async function buildWritingStyleContext(
     includeSamples = true,
     constitutionCharLimit = DEFAULT_CONSTITUTION_LIMIT,
     samplesCharLimit = DEFAULT_SAMPLES_LIMIT,
+    task = "",
+    relevantChapterCount = DEFAULT_RELEVANT_CHAPTER_COUNT,
   } = options
 
   const lines: string[] = [
     `目标文风来源:《${preset.sourceBook}》。`,
     "只模仿这种叙事密度、描写克制度、句式与节奏。严禁借用下方参考片段中的人物、地名、设定、情节——它们只是文风样例,不是剧情素材。",
     "",
-    "风格硬约束:",
-    clip(preset.profile.constitution, constitutionCharLimit),
   ]
 
-  let samples = preset.profile.samples
-  if (preset.sourceBookId && preset.evidenceIds?.length) {
+  // 硬约束永远排在最前:它是最可执行的部分,不能因为整合文档太长而被挤掉
+  lines.push("风格硬约束:", clip(preset.profile.constitution, constitutionCharLimit))
+
+  const integratedDna = preset.profile.integratedDna?.trim()
+  if (integratedDna) {
+    const { stripConstitutionSection } = await import("./book-analysis/writing-dna-prompts")
+    const body = stripConstitutionSection(integratedDna)
+    if (body) {
+      lines.push("", "Writing DNA 要点(写作时逐条对照):", clip(body, DEFAULT_DNA_BODY_LIMIT))
+    }
+  }
+
+  let samples: string[] = []
+  let sampleLabel = "文风参考片段(只学写法,不要照抄内容):"
+
+  if (preset.sourceBookId && task.trim()) {
+    try {
+      samples = await retrieveRelevantExcerpts(
+        projectPath,
+        preset.sourceBookId,
+        task,
+        relevantChapterCount,
+      )
+      if (samples.length > 0) {
+        sampleLabel = "题材最接近的原文片段(用来校准句子呼吸与段落衔接,只学写法,不要照抄内容):"
+      }
+    } catch {
+      samples = []
+    }
+  }
+
+  if (samples.length === 0 && preset.sourceBookId && preset.evidenceIds?.length) {
     const { loadEvidence } = await import("./book-analysis/analysis-evidence-store")
-    const collection = await loadEvidence(`${normalizePath(projectPath)}/book-analysis/${preset.sourceBookId}`)
+    const collection = await loadEvidence(bookPathFor(projectPath, preset.sourceBookId))
     const selectedIds = new Set(preset.evidenceIds)
     samples = collection.snippets
       .filter((item) => item.skill === "style" && item.enabled && selectedIds.has(item.id))
       .map((item) => item.text)
   }
 
+  if (samples.length === 0) samples = preset.profile.samples
+
   if (includeSamples && samples.length > 0) {
     const sampleLines: string[] = []
     let used = 0
@@ -173,7 +266,7 @@ export async function buildWritingStyleContext(
       sampleLines.push(`- ${text}`)
     }
     if (sampleLines.length > 0) {
-      lines.push("", "文风参考片段(只学写法,不要照抄内容):", ...sampleLines)
+      lines.push("", sampleLabel, ...sampleLines)
     }
   }
 

+ 149 - 0
src/stores/book-analysis-pipeline-store.model-depth.spec.ts

@@ -0,0 +1,149 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "./wiki-store"
+import type { BookAnalysisPipelineTask } from "@/lib/novel/book-analysis/analysis-pipeline-types"
+
+vi.mock("@/lib/novel/book-analysis/analysis-pipeline-storage", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("@/lib/novel/book-analysis/analysis-pipeline-storage")>()
+  return {
+    ...actual,
+    loadAndRecoverAnalysisTasks: vi.fn(async () => ({ tasks: [], chunks: [] })),
+    saveAnalysisTask: vi.fn(async () => undefined),
+    saveAnalysisChunk: vi.fn(async () => undefined),
+    replaceAnalysisTaskChunks: vi.fn(async () => undefined),
+  }
+})
+
+const schedulerOptions = vi.hoisted(() => ({ current: null as { llmConfig: unknown } | null }))
+
+vi.mock("@/lib/novel/book-analysis/analysis-scheduler", () => ({
+  createAnalysisScheduler: vi.fn((options: { llmConfig: unknown }) => {
+    schedulerOptions.current = options
+    return {
+      initialize: vi.fn(),
+      subscribe: vi.fn(() => () => undefined),
+      dispose: vi.fn(async () => undefined),
+      enqueue: vi.fn(async () => undefined),
+      pauseTask: vi.fn(async () => undefined),
+      continueTask: vi.fn(async () => undefined),
+      retryFailedChunk: vi.fn(async () => undefined),
+      cancelTask: vi.fn(async () => undefined),
+      getSnapshot: vi.fn(() => ({ tasks: [], chunks: [], progresses: {} })),
+      whenIdle: vi.fn(async () => undefined),
+    }
+  }),
+}))
+
+vi.mock("@/lib/novel/book-analysis/analysis-engine", () => ({
+  loadChapterList: vi.fn(async () => [
+    { chapterId: "ch-0001", title: "第一章", order: 1, wordCount: 1000, selected: false, analyzed: false },
+    { chapterId: "ch-0002", title: "第二章", order: 2, wordCount: 1000, selected: false, analyzed: false },
+  ]),
+  loadMetadata: vi.fn(async () => null),
+}))
+
+const usable = vi.hoisted(() => ({ value: true }))
+vi.mock("@/lib/has-usable-llm", () => ({
+  hasUsableLlm: vi.fn(() => usable.value),
+}))
+
+vi.mock("@/lib/novel/model-resolver", () => ({
+  resolveDefaultModel: vi.fn((base: LlmConfig) => ({ ...base, model: "default-model", maxContextSize: 128_000 })),
+  resolveModelConfig: vi.fn((modelKey: string, base: LlmConfig) => ({
+    ...base,
+    model: modelKey,
+    maxContextSize: 32_000,
+  })),
+}))
+
+async function makeStoreWithTask(projectPath: string, bookId: string) {
+  const { createBookAnalysisPipelineStore } = await import("./book-analysis-pipeline-store")
+  const store = createBookAnalysisPipelineStore()
+  await store.getState().initializeProject(projectPath)
+  const task = await store.getState().createAwaitingRangeTask({
+    bookId,
+    bookPath: `${projectPath}/book-analysis/${bookId}`,
+    selectedSkills: ["style"],
+    forceNew: true,
+  })
+  return { store, taskId: task!.id }
+}
+
+describe("book-analysis-pipeline-store 模型与文风深度", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    usable.value = true
+    schedulerOptions.current = null
+  })
+
+  it("configureTaskRange 把 modelKey 与 styleDepth 写到任务上", async () => {
+    const { store, taskId } = await makeStoreWithTask("E:/Novel-depth", "book-1")
+
+    await store.getState().configureTaskRange(taskId, { startOrder: 1, endOrder: 2 }, ["style"], {
+      modelKey: "openai/gpt-4o-mini",
+      styleDepth: "fast",
+    })
+
+    const updated = store.getState().tasks.find((item) => item.id === taskId)
+    expect(updated?.modelKey).toBe("openai/gpt-4o-mini")
+    expect(updated?.styleDepth).toBe("fast")
+  })
+
+  it("模型留空时不写 modelKey,后续按默认模型解析", async () => {
+    const { store, taskId } = await makeStoreWithTask("E:/Novel-depth-empty", "book-2")
+
+    await store.getState().configureTaskRange(taskId, { startOrder: 1, endOrder: 2 }, ["style"], {
+      modelKey: "   ",
+      styleDepth: "full",
+    })
+
+    const updated = store.getState().tasks.find((item) => item.id === taskId)
+    expect(updated?.modelKey).toBeUndefined()
+    expect(updated?.styleDepth).toBe("full")
+  })
+
+  it("重新配置范围但不传 options 时保留原有模型与深度", async () => {
+    const { store, taskId } = await makeStoreWithTask("E:/Novel-depth-keep", "book-3")
+    await store.getState().configureTaskRange(taskId, { startOrder: 1, endOrder: 2 }, ["style"], {
+      modelKey: "openai/gpt-4o-mini",
+      styleDepth: "fast",
+    })
+
+    await store.getState().configureTaskRange(taskId, { startOrder: 1, endOrder: 2 }, ["style"])
+
+    const updated = store.getState().tasks.find((item) => item.id === taskId)
+    expect(updated?.modelKey).toBe("openai/gpt-4o-mini")
+    expect(updated?.styleDepth).toBe("fast")
+  })
+
+  it("分片规划按任务模型的上下文窗口计算", async () => {
+    const { resolveModelConfig } = await import("@/lib/novel/model-resolver")
+    const { store, taskId } = await makeStoreWithTask("E:/Novel-depth-plan", "book-4")
+
+    await store.getState().configureTaskRange(taskId, { startOrder: 1, endOrder: 2 }, ["style"], {
+      modelKey: "openai/gpt-4o-mini",
+    })
+
+    expect(resolveModelConfig).toHaveBeenCalledWith("openai/gpt-4o-mini", expect.anything(), expect.anything())
+  })
+
+  it("scheduler 的 llmConfig 按任务解析:选了模型走 resolveModelConfig,没选走默认", async () => {
+    const { resolveDefaultModel, resolveModelConfig } = await import("@/lib/novel/model-resolver")
+    await makeStoreWithTask("E:/Novel-depth-scheduler", "book-5")
+    const resolve = schedulerOptions.current?.llmConfig as (task: Pick<BookAnalysisPipelineTask, "modelKey">) => LlmConfig
+
+    expect(resolve({ modelKey: "openai/gpt-4o-mini" }).model).toBe("openai/gpt-4o-mini")
+    expect(resolveModelConfig).toHaveBeenCalled()
+    expect(resolve({ modelKey: undefined }).model).toBe("default-model")
+    expect(resolveDefaultModel).toHaveBeenCalled()
+  })
+
+  it("所选模型不可用时启动任务报错并点名该模型", async () => {
+    const { store, taskId } = await makeStoreWithTask("E:/Novel-depth-unusable", "book-6")
+    await store.getState().configureTaskRange(taskId, { startOrder: 1, endOrder: 2 }, ["style"], {
+      modelKey: "openai/broken-model",
+    })
+    usable.value = false
+
+    await expect(store.getState().startTask(taskId)).rejects.toThrow(/openai\/broken-model/)
+  })
+})

+ 21 - 8
src/stores/book-analysis-pipeline-store.ts

@@ -19,6 +19,7 @@ import {
   type AnalysisRuntimeProgress,
   type AnalysisSkill,
   type BookAnalysisPipelineTask,
+  type StyleAnalysisDepth,
 } from "@/lib/novel/book-analysis/analysis-pipeline-types"
 import type { RecognizedCharacter } from "@/lib/novel/book-analysis/types"
 import { createAnalysisScheduler, type AnalysisScheduler } from "@/lib/novel/book-analysis/analysis-scheduler"
@@ -26,7 +27,7 @@ import { characterAnalysisAdapter } from "@/lib/novel/book-analysis/character-an
 import { storyAnalysisAdapter } from "@/lib/novel/book-analysis/story-analysis-adapter"
 import { styleAnalysisAdapter } from "@/lib/novel/book-analysis/style-analysis-adapter"
 import { clearActiveAnalysisSnapshot, setActiveAnalysisSnapshot } from "@/lib/novel/book-analysis/analysis-active-registry"
-import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import { resolveTaskLlmConfig } from "@/lib/novel/book-analysis/analysis-model-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 
 let taskCounter = 0
@@ -93,7 +94,12 @@ interface BookAnalysisPipelineState {
     selectedSkills: AnalysisSkill[]
     forceNew?: boolean
   }): Promise<BookAnalysisPipelineTask | null>
-  configureTaskRange(taskId: string, range: AnalysisChapterRange, selectedSkills?: AnalysisSkill[]): Promise<void>
+  configureTaskRange(
+    taskId: string,
+    range: AnalysisChapterRange,
+    selectedSkills?: AnalysisSkill[],
+    options?: { modelKey?: string; styleDepth?: StyleAnalysisDepth },
+  ): Promise<void>
   setTaskRecognizedCharacters(taskId: string, characters: RecognizedCharacter[]): Promise<void>
   failTask(taskId: string, error: string): Promise<void>
   confirmCharacterSelection(taskId: string, selectedIds: string[]): Promise<void>
@@ -154,7 +160,7 @@ export function createBookAnalysisPipelineStore() {
           story: storyAnalysisAdapter,
           style: styleAnalysisAdapter,
         },
-        llmConfig: () => resolveDefaultModel(useWikiStore.getState().llmConfig),
+        llmConfig: (task) => resolveTaskLlmConfig(task),
       })
       scheduler = nextScheduler
       nextScheduler.initialize(mergedTasks, mergedChunks)
@@ -197,13 +203,16 @@ export function createBookAnalysisPipelineStore() {
       setActiveAnalysisSnapshot(projectPath, [...get().tasks])
       return task
     },
-    async configureTaskRange(taskId, range, nextSkills) {
+    async configureTaskRange(taskId, range, nextSkills, options) {
       const task = get().tasks.find((item) => item.id === taskId)
       if (!task) throw new Error("未找到分析任务")
       const selectedSkills = normalizeSelectedSkills(nextSkills ?? task.selectedSkills)
       if (selectedSkills.length === 0) throw new Error("请至少选择一个提取项目")
+      const modelKey = options?.modelKey?.trim() ?? task.modelKey ?? ""
+      const styleDepth = options?.styleDepth ?? task.styleDepth
       const chapters = await loadChapterList(task.bookPath)
-      const llmConfig = resolveDefaultModel(useWikiStore.getState().llmConfig)
+      // 仍按任务模型解析 maxContextSize,但 computeAnalysisChunkCharLimit 当前对所有已配置模型都返回 40000
+      const llmConfig = resolveTaskLlmConfig({ modelKey })
       const plan = buildAnalysisChunkPlan(
         chapters.map((chapter) => ({ id: chapter.chapterId, order: chapter.order, wordCount: chapter.wordCount })),
         range,
@@ -215,6 +224,8 @@ export function createBookAnalysisPipelineStore() {
         ...task,
         selectedSkills,
         range,
+        modelKey: modelKey || undefined,
+        styleDepth,
         status: needsCharacterSelection ? "awaiting-character-selection" : "queued",
         currentSkill: null,
         error: null,
@@ -339,9 +350,11 @@ export function createBookAnalysisPipelineStore() {
       if (task.status === "awaiting-character-selection") {
         throw new Error("请先选择要深度分析的角色")
       }
-      const wikiState = useWikiStore.getState()
-      if (!hasUsableLlm(resolveDefaultModel(wikiState.llmConfig), wikiState.providerConfigs)) {
-        throw new Error("未配置可用模型,请先在设置中配置默认模型")
+      const { providerConfigs } = useWikiStore.getState()
+      if (!hasUsableLlm(resolveTaskLlmConfig(task), providerConfigs)) {
+        throw new Error(task.modelKey
+          ? `所选模型「${task.modelKey}」不可用,请重新选择模型或在设置中补全其配置`
+          : "未配置可用模型,请先在设置中配置默认模型")
       }
       await current.enqueue(task, get().chunks.filter((chunk) => chunk.taskId === taskId))
     },