diff --git a/frontend-web/src/open-canvas/components/ai-writing/OutlineSection.tsx b/frontend-web/src/open-canvas/components/ai-writing/OutlineSection.tsx
index b4dfeeec..b4d2af8f 100644
--- a/frontend-web/src/open-canvas/components/ai-writing/OutlineSection.tsx
+++ b/frontend-web/src/open-canvas/components/ai-writing/OutlineSection.tsx
@@ -1,7 +1,8 @@
-import { useState } from 'react';
+import { useState, useMemo } from 'react';
import { ChevronRight, Plus, X, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
-import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing';
+import type { KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
+import { normalizeKeyPoint } from '../../types/ai-writing';
import { CollapseTransition } from './CollapseTransition';
interface OutlineSectionProps {
@@ -10,7 +11,7 @@ interface OutlineSectionProps {
onChange: (updated: OutlineSectionType) => void;
onRemove?: () => void;
readOnly?: boolean;
- // Drag-and-drop
+ // Drag-and-drop (章节级别)
isDragging?: boolean;
isOver?: boolean;
onDragStart?: () => void;
@@ -19,6 +20,14 @@ interface OutlineSectionProps {
onDragEnd?: () => void;
}
+/**
+ * 章节卡片(Level 1)。内部渲染 keyPoints[] 为「小节子卡片」(Level 2),小节里再渲染
+ * subPoints[] 为 bullet 列表(Level 3)。
+ *
+ * 兼容旧 2 级大纲:section.keyPoints 可能是 (string | KeyPoint)[];进入组件前通过
+ * normalizeKeyPoint 统一成 KeyPoint[],对外输出也一律是 KeyPoint[](自然升级,用户
+ * 编辑过的旧 session 大纲会被持久化为新格式)。
+ */
export function OutlineSection({
section, index, onChange, onRemove, readOnly,
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
@@ -27,6 +36,13 @@ export function OutlineSection({
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
+ // 把 keyPoints 统一成 KeyPoint[];旧 string 元素会被 lift。useMemo 避免每次 render
+ // 都生成新引用导致子组件无谓重渲染。
+ const keyPoints: KeyPoint[] = useMemo(
+ () => (section.keyPoints ?? []).map(normalizeKeyPoint),
+ [section.keyPoints],
+ );
+
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
@@ -37,20 +53,23 @@ export function OutlineSection({
}
}
- function updateKeyPoint(i: number, value: string) {
- const next = [...section.keyPoints];
- next[i] = value;
+ function updateKeyPoint(i: number, updated: KeyPoint) {
+ const next = [...keyPoints];
+ next[i] = updated;
onChange({ ...section, keyPoints: next });
}
function removeKeyPoint(i: number) {
- onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) });
+ onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) });
}
function addKeyPoint() {
- onChange({ ...section, keyPoints: [...section.keyPoints, ''] });
+ onChange({ ...section, keyPoints: [...keyPoints, { pointTitle: '', subPoints: [] }] });
}
+ // 子要点总数,显示在章节头给个粗略概览
+ const totalSubPoints = keyPoints.reduce((sum, kp) => sum + (kp.subPoints?.length || 0), 0);
+
return (
)}
- {section.keyPoints.length} 要点
+
+ {keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''}
+
{!readOnly && onRemove && (
- {/* 要点列表 */}
+ {/* 小节列表 (Level 2) */}
-
- {section.keyPoints.map((kp, i) => (
-
-
•
+
+ {keyPoints.map((kp, i) => (
+
updateKeyPoint(i, updated)}
+ onRemove={readOnly ? undefined : () => removeKeyPoint(i)}
+ readOnly={readOnly}
+ />
+ ))}
+ {!readOnly && (
+
+ )}
+
+
+
+ );
+}
+
+// ─── 小节行(Level 2,内嵌 Level 3 子要点) ───────────────────────────────────────
+interface KeyPointRowProps {
+ keyPoint: KeyPoint;
+ onChange: (updated: KeyPoint) => void;
+ onRemove?: () => void;
+ readOnly?: boolean;
+}
+
+function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProps) {
+ const [expanded, setExpanded] = useState(false);
+ const [editingTitle, setEditingTitle] = useState(false);
+ const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle);
+
+ function commitTitle() {
+ setEditingTitle(false);
+ const trimmed = titleDraft.trim();
+ if (trimmed !== keyPoint.pointTitle) {
+ onChange({ ...keyPoint, pointTitle: trimmed });
+ }
+ }
+
+ function updateSubPoint(i: number, value: string) {
+ const next = [...keyPoint.subPoints];
+ next[i] = value;
+ onChange({ ...keyPoint, subPoints: next });
+ }
+
+ function removeSubPoint(i: number) {
+ onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) });
+ }
+
+ function addSubPoint() {
+ onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] });
+ setExpanded(true); // 添加时自动展开,免得用户看不到刚加的输入框
+ }
+
+ return (
+
+ {/* 小节头 */}
+
+
+
+ ###
+
+ {editingTitle && !readOnly ? (
+ setTitleDraft(e.target.value)}
+ onBlur={commitTitle}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') commitTitle();
+ if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); }
+ }}
+ className="flex-1 bg-transparent border-b border-primary outline-none"
+ placeholder="小节标题"
+ />
+ ) : (
+ !readOnly && setEditingTitle(true)}
+ >
+ {keyPoint.pointTitle || (未命名小节)}
+
+ )}
+
+
+ {keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'}
+
+ {!readOnly && onRemove && (
+
+ )}
+
+
+ {/* 子要点 (Level 3) — 每个子要点会变成正文里的 #### 标题 + 一段独立论述 */}
+
+
+ {keyPoint.subPoints.map((sp, i) => (
+
+ {/* 用 #### 标记替代 bullet,提示这是「四级标题」而非简单列表项 */}
+
####
{readOnly ? (
-
{kp}
+
{sp}
) : (
updateKeyPoint(i, e.target.value)}
- placeholder="输入要点…"
+ value={sp}
+ onChange={(e) => updateSubPoint(i, e.target.value)}
+ placeholder="子要点标题(会展开成一段正文)"
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
/>
)}
{!readOnly && (
-
diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts
index 76fac065..e73e703c 100644
--- a/frontend-web/src/open-canvas/types/ai-writing.ts
+++ b/frontend-web/src/open-canvas/types/ai-writing.ts
@@ -13,10 +13,25 @@ export interface AIMaterial {
}
// ── 大纲 ─────────────────────────────────────────────────────────────────────
+// 三级结构:章节(##) → 小节(###) → 子要点(bullet)
+// AIOutline.sections[] = Level 1 章节(渲染为 ## 由后端注入)
+// OutlineSection.keyPoints[] = Level 2 小节(渲染为 ###)
+// KeyPoint.subPoints[] = Level 3 子要点(正文里的 bullet 列表)
+//
+// 历史兼容:旧版 keyPoints: string[](2 级)。后端 _parse_outline 已经在解析时把
+// 旧 string 自动 lift 成 KeyPoint;前端类型只保留新格式,但 UI 渲染层做 type
+// guard 兼容,防止前端流式接收到旧大纲时崩溃。详见 normalizeKeyPoint() helper。
+
+export interface KeyPoint {
+ pointTitle: string; // 小节标题(渲染为 ### 三级标题)
+ subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
+}
export interface OutlineSection {
sectionTitle: string;
- keyPoints: string[];
+ // 历史 session 可能是 string[](旧 2 级结构),用 normalizeKeyPoint 转换。
+ // 新生成的大纲一律 KeyPoint[]。
+ keyPoints: Array
;
materialIds: string[];
}
@@ -25,6 +40,32 @@ export interface AIOutline {
sections: OutlineSection[];
}
+/**
+ * 把 keyPoints 单项规整成 KeyPoint。兼容三种输入:
+ * - 新版 KeyPoint object
+ * - 旧版 string (lift 为 { pointTitle: , subPoints: [] })
+ * - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常)
+ */
+export function normalizeKeyPoint(raw: unknown): KeyPoint {
+ if (typeof raw === 'string') {
+ return { pointTitle: raw, subPoints: [] };
+ }
+ if (raw && typeof raw === 'object') {
+ const r = raw as Record;
+ const title =
+ typeof r.pointTitle === 'string' ? r.pointTitle :
+ typeof r.point_title === 'string' ? r.point_title :
+ typeof r.title === 'string' ? r.title :
+ '';
+ const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown;
+ const subPoints: string[] = Array.isArray(subsRaw)
+ ? subsRaw.map((s) => (typeof s === 'string' ? s : String(s ?? ''))).filter(Boolean)
+ : [];
+ return { pointTitle: title, subPoints };
+ }
+ return { pointTitle: '', subPoints: [] };
+}
+
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
/** 严格模式下素材不足、待用户决策的章节 */
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py
index c172541d..5578a6f5 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py
@@ -16,18 +16,70 @@ from ._utils import get_model, _content_to_text, count_words, ThinkTagParser, sa
logger = logging.getLogger(__name__)
-# 章节正文里的 Markdown 标题行(允许前导空白)
-_HEADING_PREFIX_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+", re.MULTILINE)
+# 章节正文里的 Markdown 标题行(允许前导空白) — 分组捕获缩进 + 井号串
+_HEADING_PREFIX_RE = re.compile(r"^([ \t]*)(#{1,6})[ \t]+", re.MULTILINE)
def _normalize_section_headings(text: str) -> str:
- """把章节正文里所有 Markdown 标题行统一压成三级标题(###)。
+ """把章节正文里的 Markdown 标题层级归一到允许的两级:### / ####。
- 作家被要求只用 ### 作为「要点」小标题;模型偶尔误用 #/##/#### 时在此纠正,
- 保证文档标题层级符合「文章 # → 章节 ## → 要点 ###」,
- 前端目录栏据此才能把要点正确识别为二级标题。
+ 文档层级:文章 `#` → 章节 `##` → 小节 `###` → 子要点 `####`。
+ 作家只输出小节(###)和子要点(####)两级,文章/章节标题由系统注入。
+
+ 归一规则:
+ - `#` / `##` → `###` 作家不应输出这两级,误用时降级为小节
+ - `###` → `###` 保持
+ - `####` → `####` 保持(三级结构的最深层)
+ - `#####`+ → `####` 深度截断,防止结构过深
"""
- return _HEADING_PREFIX_RE.sub("### ", text)
+
+ def _replace(match: re.Match[str]) -> str:
+ indent = match.group(1)
+ level = len(match.group(2))
+ # 三级及以下保持原级别(限 3 和 4);深度 4+ 压回 4
+ new_level = 3 if level <= 3 else 4
+ return f"{indent}{'#' * new_level} "
+
+ return _HEADING_PREFIX_RE.sub(_replace, text)
+
+
+def _render_key_points_for_prompt(key_points: list) -> str:
+ """把 outline 的 key_points 渲染成层级文本喂给作家。
+
+ 输入是 List[KeyPoint] 或 List[str](旧 session 兼容)。输出形如:
+
+ - 小节标题1
+ - 子要点 a
+ - 子要点 b
+ - 小节标题2(无子要点时只一行)
+
+ 作家据此把每个主 bullet 写成 `### 小节标题`,把缩进的子要点
+ 用 `- ` bullet 列表落在小节正文起始处,再展开论述。
+ """
+ lines: list[str] = []
+ for kp in key_points:
+ if isinstance(kp, str):
+ # 旧版兼容:整条当作小节标题,没有子要点
+ title = kp.strip()
+ if title:
+ lines.append(f"- {title}")
+ elif isinstance(kp, dict):
+ title = str(
+ kp.get("point_title")
+ or kp.get("pointTitle")
+ or kp.get("title")
+ or ""
+ ).strip()
+ if not title:
+ continue
+ lines.append(f"- {title}")
+ subs = kp.get("sub_points") or kp.get("subPoints") or []
+ if isinstance(subs, list):
+ for sp in subs:
+ sp_text = sp.strip() if isinstance(sp, str) else str(sp).strip()
+ if sp_text:
+ lines.append(f" - {sp_text}")
+ return "\n".join(lines) or "- 主要内容"
# 疑似「拒绝 / 元话语」的强特征短语:模型把对用户说的话当成正文输出时出现,
@@ -166,7 +218,9 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
mat_ids = section.get("material_ids") or section.get("materialIds") or []
section_materials = _get_section_materials(mat_ids, pkg)
- key_points_str = "\n".join(f"- {p}" for p in key_points)
+ # 三级结构:把每个 KeyPoint 展开成「小节标题 + 缩进子要点」喂给作家。
+ # 兼容旧 session 里 key_points 为 List[str] 的情况(走相同 helper)。
+ key_points_str = _render_key_points_for_prompt(key_points)
# 文章整体结构:让作家看到全文章节与自己的定位,避免章节间内容重复、引言结论脱节
outline_context = "\n".join(
@@ -282,8 +336,8 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
expand_msg = DRAFT_EXPAND_USER.format(
article_title=article_title,
section_title=section_titles[idx],
- key_points="\n".join(
- f"- {p}" for p in (target_sec.get("key_points") or target_sec.get("keyPoints") or [])
+ key_points=_render_key_points_for_prompt(
+ target_sec.get("key_points") or target_sec.get("keyPoints") or []
),
section_materials=_get_section_materials(
target_sec.get("material_ids") or target_sec.get("materialIds") or [], pkg
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py
index 82108575..cf8c01df 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py
@@ -7,7 +7,7 @@ import logging
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
-from ..state import AIWritingState, Outline, OutlineSection
+from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
from ..prompts.writer_prompts import (
OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
)
@@ -27,16 +27,56 @@ def _materials_summary(pkg: dict) -> str:
return "\n".join(lines) or "(无素材)"
+def _normalize_key_point(raw: object) -> KeyPoint:
+ """把 LLM 输出 / 旧 session 数据规整成 KeyPoint。
+
+ 兼容场景:
+ - 新版 dict:`{"point_title": "...", "sub_points": [...]}` —— 直接用,缺字段补默认
+ - 旧版 string:历史 session 的 `key_points: ["要点1", "要点2"]` —— 自动 lift 成
+ `{"point_title": "要点1", "sub_points": []}`,旧大纲打开后能继续编辑
+ - LLM 偶尔会用 `title` / `subPoints` 这种 camelCase 或别名,做轻量兜底
+ - 其它无法识别的形态(数字 / 列表 / None)→ 空 KeyPoint,不让整个大纲炸掉
+ """
+ if isinstance(raw, str):
+ return KeyPoint(point_title=raw, sub_points=[])
+ if isinstance(raw, dict):
+ # 字段别名兜底(LLM 时常 camelCase 或写 title):优先 snake_case
+ title = (
+ raw.get("point_title")
+ or raw.get("pointTitle")
+ or raw.get("title")
+ or ""
+ )
+ subs_raw = raw.get("sub_points") or raw.get("subPoints") or []
+ # 子要点统一为字符串数组;dict 子项取其 title 字段降级(避免 4 级嵌套混入)
+ sub_points: list[str] = []
+ if isinstance(subs_raw, list):
+ for sp in subs_raw:
+ if isinstance(sp, str):
+ sub_points.append(sp)
+ elif isinstance(sp, dict):
+ sub_points.append(str(sp.get("point_title") or sp.get("title") or sp.get("text") or ""))
+ return KeyPoint(point_title=str(title), sub_points=sub_points)
+ # 其它意外类型(int / list / None):返回空 KeyPoint
+ return KeyPoint(point_title="", sub_points=[])
+
+
def _parse_outline(raw: dict) -> Outline:
sections = []
for s in raw.get("sections", []):
+ raw_kps = s.get("key_points") or []
+ key_points = [_normalize_key_point(kp) for kp in raw_kps]
sections.append(OutlineSection(
section_title=s.get("section_title", ""),
- key_points=s.get("key_points", []),
+ key_points=key_points,
material_ids=s.get("material_ids", []),
))
if not sections:
- sections = [OutlineSection(section_title="正文", key_points=["主要内容"], material_ids=[])]
+ sections = [OutlineSection(
+ section_title="正文",
+ key_points=[KeyPoint(point_title="主要内容", sub_points=[])],
+ material_ids=[],
+ )]
return Outline(title=raw.get("title", "未命名文章"), sections=sections)
@@ -120,12 +160,32 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
if is_revise:
fallback = current_outline
else:
+ # 兜底大纲也用新的 3 级结构;_normalize_key_point 同时兼容旧 string 形态,
+ # 这里给 dict 形态做样板。
fallback = {
"title": "未命名文章",
"sections": [
- {"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []},
- {"section_title": "正文", "key_points": ["核心内容"], "material_ids": []},
- {"section_title": "结论", "key_points": ["总结"], "material_ids": []},
+ {
+ "section_title": "引言",
+ "key_points": [
+ {"point_title": "背景介绍", "sub_points": []},
+ ],
+ "material_ids": [],
+ },
+ {
+ "section_title": "正文",
+ "key_points": [
+ {"point_title": "核心内容", "sub_points": []},
+ ],
+ "material_ids": [],
+ },
+ {
+ "section_title": "结论",
+ "key_points": [
+ {"point_title": "总结", "sub_points": []},
+ ],
+ "material_ids": [],
+ },
],
}
result = parse_json(full_text, fallback)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py
index 24fdc38a..eb48f278 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py
@@ -1,10 +1,10 @@
OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
-你的目标:根据素材和意图,规划清晰的文章大纲。
+你的目标:根据素材和意图,规划清晰的三级大纲(章节 → 小节 → 子要点)。
你的工作背景:你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。
你只能做以下事情:
1. 分析素材,提取核心观点
-2. 规划文章章节结构和每节要点
+2. 规划文章三级结构:章节、章节下的小节、小节下的子要点
你不能写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。
【严禁思考与表态】(最高优先级)
@@ -12,10 +12,16 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
- 禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲");无论素材是否充分,都必须直接产出大纲 JSON。
- 收到输入后立即、直接输出最终 JSON,不得有任何前置内容。
-【要点(key_points)的特殊要求】
-- 每个要点都会作为文章里的一个二级小标题呈现,所以要点必须写成"标题式短语"。
-- 用简洁的名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。
-- 每个章节规划 2-4 个要点,不要过多,避免文章被切得过碎。
+【三级层级语义】(最高优先级,决定文章结构)
+- Level 1 = `section_title`(章节):将渲染为文章里的 `##` 二级标题,统领一组内容。
+- Level 2 = `point_title`(小节):将渲染为 `###` 三级标题,下面会有一段 2-3 句的概述/引入正文。
+- Level 3 = `sub_points`(子要点):将渲染为 `####` 四级标题,**每个子要点都是一个有自己 3-5 句独立正文段的小标题**——不是 bullet 列表。
+ → 因此子要点要规划成「能独立成段展开」的小主题,而不是一句话能讲完的零散点。
+
+【三级字段的具体要求】
+- `section_title`(章节标题):高度概括的短语,6-15 字。每篇文章规划 3-5 个章节。
+- `point_title`(小节标题):名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。每个章节规划 2-4 个小节。
+- `sub_points`(子要点标题):名词性短语,<= 15 字,**不带句末标点**。每个小节规划 2-4 条子要点;如果该小节信息确实少到无法拆分子主题,可留 `[]`(此时只写小节级正文)。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
@@ -27,9 +33,31 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
{
"title": "新能源汽车出口为何高速增长",
"sections": [
- {"section_title": "引言:出口数据创新高", "key_points": ["出口量同比增幅", "全球市场排名变化"], "material_ids": ["m001"]},
- {"section_title": "增长背后的三大动因", "key_points": ["产业链成本优势", "海外政策利好", "产品竞争力提升"], "material_ids": ["m002", "m003"]},
- {"section_title": "结论与展望", "key_points": ["未来增长挑战", "行业趋势判断"], "material_ids": ["m004"]}
+ {
+ "section_title": "引言:出口数据创新高",
+ "key_points": [
+ {"point_title": "出口量同比增幅", "sub_points": ["2024 年累计出口规模", "同比增速对比 2023"]},
+ {"point_title": "全球市场排名变化", "sub_points": ["对欧出口跃升", "对东盟首次反超日韩"]}
+ ],
+ "material_ids": ["m001"]
+ },
+ {
+ "section_title": "增长背后的三大动因",
+ "key_points": [
+ {"point_title": "产业链成本优势", "sub_points": ["电池原料端议价能力", "整车规模效应"]},
+ {"point_title": "海外政策利好", "sub_points": ["欧盟碳关税倒逼", "中东购车补贴"]},
+ {"point_title": "产品竞争力提升", "sub_points": ["智能化配置溢价", "出海车型迭代速度"]}
+ ],
+ "material_ids": ["m002", "m003"]
+ },
+ {
+ "section_title": "结论与展望",
+ "key_points": [
+ {"point_title": "未来增长挑战", "sub_points": ["欧盟反补贴调查", "海外渠道建设成本"]},
+ {"point_title": "行业趋势判断", "sub_points": []}
+ ],
+ "material_ids": ["m004"]
+ }
]
}"""
@@ -46,7 +74,7 @@ OUTLINE_USER = """写作意图:{user_intent}
# ── 重新规划(基于现有大纲的编辑)─────────────────────────────────────────────
OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
-你的目标:根据用户的修改要求,调整一份【已有的文章大纲】。
+你的目标:根据用户的修改要求,调整一份【已有的三级文章大纲】。
你的工作背景:你有十年内容创作经验,擅长精准理解修改意图并落实到大纲结构上。
你只能做以下事情:
@@ -54,25 +82,31 @@ OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
你不能写正文,只输出调整后的大纲。
【修改原则】(最高优先级)
-- 准确识别并落实用户意图,常见操作:新增章节、删除章节、合并章节、拆分章节、调整章节顺序、修改章节标题、调整章节要点。
-- 只改用户明确提到的部分:用户没要求改动的章节,必须原样保留(标题、要点、material_ids 全部不变)。
-- 新增章节时:紧扣写作意图补充合理章节,并尽量关联可用素材的 material_ids。
-- 章节数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
+- 准确识别并落实用户意图,常见操作:新增/删除/合并/拆分章节或小节、调整顺序、修改标题、调整子要点。
+- 只改用户明确提到的部分:用户没要求改动的章节、小节、子要点必须原样保留(标题、内容、material_ids 全部不变)。
+- 新增内容时:紧扣写作意图,并尽量关联可用素材的 material_ids。
+- 章节/小节/子要点数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
【严禁思考与表态】(最高优先级)
- 禁止输出任何思考、分析、规划过程,禁止 标签及其中内容。
- 禁止输出任何对用户说的话;收到输入后立即、直接输出最终 JSON。
-【要点(key_points)的特殊要求】
-- 每个要点会作为文章里的二级小标题呈现,写成简洁名词性短语,15 字以内,不带句末标点。
-- 每个章节 2-4 个要点。
+【三级层级语义】(最高优先级)
+- `section_title`(章节,`##` 二级标题):高度概括的短语,6-15 字。
+- `point_title`(小节,`###` 三级标题):名词性短语,15 字以内,无句末标点;小节下会有一段概述正文。每章节 2-4 个小节。
+- `sub_points`(子要点,`####` 四级标题):名词性短语,<= 15 字,无句末标点;**每条子要点会展开成一段独立正文**,所以要写成能成段的主题。每小节 2-4 条,信息少到无法拆时可留 `[]`。
+
+【兼容历史大纲】
+- 若「当前大纲」里的 `key_points` 是字符串数组(旧版 2 级结构),保留原值视为小节标题,
+ 在补充 `sub_points: []` 后输出新格式:`{"point_title": "<原 string>", "sub_points": []}`。
+- 用户修改时如要求"为某要点补充更详细的子项",就把对应小节的 sub_points 填上。
【输出要求】
- 只输出一个调整后的【完整】大纲 JSON 对象,不要解释文字,不要 markdown 代码块。
- 必须使用英文双引号 "。
- material_ids 中的 ID 必须来自给定素材,没有合适素材时填 []。
-- 结构与「当前大纲」一致:
-{"title": "...", "sections": [{"section_title": "...", "key_points": ["..."], "material_ids": ["..."]}]}"""
+- 结构(嵌套三级):
+{"title": "...", "sections": [{"section_title": "...", "key_points": [{"point_title": "...", "sub_points": ["..."]}], "material_ids": ["..."]}]}"""
OUTLINE_REVISE_USER = """写作意图:{user_intent}
文章类型:{article_type}
@@ -104,18 +138,23 @@ DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
- 禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材""素材与意图不匹配"等——这类话绝不允许出现在文章里。
- 无论素材是否充分,你都必须直接产出文章正文本身;收到输入后立即开始写正文(含 ### 小标题),不要有任何前置说明。
-【结构要求】
-- 把本章节的每一个「章节要点」写成一个二级小标题,用 Markdown 三级标题语法:### 要点
-- 每个 ### 小标题下面紧跟该要点的论述正文,正文连贯成段。
-- 即使章节只有一个要点,也要为它写出对应的 ### 小标题。
-- 小标题文字简洁,直接沿用给定的要点措辞,不要加序号、不要加标点。
-- 只能使用 ### 这一级标题;绝不要输出 # 或 ##(文章标题、章节标题由系统统一添加)。
+【结构要求】(三级结构,必须严格遵守)
+- 「章节要点」用层级 bullet 列表给出:
+ - 主要 bullet(顶格)= 小节标题,对应 Markdown `###` 三级标题
+ - 缩进 bullet(带前导空格)= 该小节下的子要点,对应 Markdown `####` 四级标题
+- **每个小节、每个子要点都要有自己的正文段,分两层展开**:
+ 1. `### 小节标题` 下先写一段「概述/引入正文」(2-3 句),说明本小节要讨论什么、几个子要点之间的关系。
+ 2. 然后对每个子要点:`#### 子要点标题` 单起一行,再展开 3-5 句具体论述(结合素材中的事实/数据)。
+- 若小节**没有子要点**(缩进 bullet 为空):在 `### 小节标题` 下直接连贯成段论述,**不出现 ####**。
+- 即使章节只有一个小节,也要为它写出对应的 `###` 标题。
+- 标题层级严格对应:文章 `#` / 章节 `##`(系统注入,作家**不要输出**)/ 小节 `###` / 子要点 `####`。
+- 不要输出 `#####` 或更深;这种深度不在结构里。
【输出要求】
-- 直接返回这一段的内容(含 ### 小标题与正文),不要 JSON,不要解释。
+- 直接返回这一段的内容(含 `### 小节标题` + 概述段 + `#### 子要点标题` + 各自正文),不要 JSON,不要解释。
- 不要写"以下是正文""本段内容如下"之类的开场白。
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
-- 充分展开每个要点:每个要点至少用 2-3 句话论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
+- 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
# ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ───────────────────
@@ -141,17 +180,25 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
{outline_context}
当前章节:{section_title}
-章节要点:{key_points}
+章节内的小节与子要点(层级 bullet 列表):
+{key_points}
相关素材:
{section_materials}
{revision_section}{mode_section}
请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。
-把上述每个章节要点写成一个 ### 二级小标题,并在标题下展开论述,论述充分、内容充实。"""
+
+按下面的结构两层展开写:
+- 每个主要 bullet(顶格)→ `### 小节标题`,标题下先写 2-3 句概述/引入正文。
+- 该小节的每个缩进 bullet(子要点)→ `#### 子要点标题`,标题下展开 3-5 句具体论述。
+- 没有缩进 bullet 的小节,直接在 ### 标题下连贯成段论述,不出现 ####。
+
+论述充分、层次清晰、内容充实。"""
DRAFT_EXPAND_USER = """文章标题:{article_title}
当前章节:{section_title}
-章节要点:{key_points}
+章节内的小节与子要点(层级 bullet 列表):
+{key_points}
相关素材:
{section_materials}
@@ -159,8 +206,9 @@ DRAFT_EXPAND_USER = """文章标题:{article_title}
这一章节已有内容如下:
{existing_text}
-以上内容篇幅不足。请在保留原意、事实和 ### 二级小标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。
+以上内容篇幅不足。请在保留原意、事实和「### 小节 / #### 子要点」两级标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。
要求:
-- 补充更多来自素材的事实、数据与分析,论述更充分、更具体。
-- 保留并沿用原有的 ### 小标题(每个章节要点对应一个),只用 ### 这一级标题,不要输出 # 或 ##。
-- 直接返回扩充后的完整章节内容(含 ### 小标题),不要解释。"""
+- 补充更多来自素材的事实、数据与分析,让每一层都论述更充分、更具体。
+- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
+- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
+- 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py
index b958e2a7..a64c3321 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py
@@ -20,10 +20,23 @@ class MaterialPackage(TypedDict):
summary: str
# ── 大纲 ──────────────────────────────────────────────────────────────────────
+# 三级结构:章节(##) → 小节(###) → 子要点(bullet -)。
+# Outline.sections[] = Level 1 章节(渲染为 ## 由系统注入)
+# OutlineSection.key_points[] = Level 2 小节(渲染为 ### 标题)
+# KeyPoint.sub_points[] = Level 3 子要点(bullet "- ...",非标题)
+#
+# 历史上 key_points 曾是 List[str](2 级):每个 str 既是小节标题又是渲染单位。
+# 升级到 3 级后,key_points 变成 List[KeyPoint],每个 KeyPoint 自带 sub_points
+# 列表;旧 session 里的 string 项由 writer_outline._parse_outline 自动 lift 成
+# {point_title: <原 string>, sub_points: []},无需迁移数据。
+
+class KeyPoint(TypedDict):
+ point_title: str # 小节标题(渲染为 ### 二级小标题)
+ sub_points: List[str] # 子要点(可为空;非空时在小节正文里以 bullet 列表呈现)
class OutlineSection(TypedDict):
section_title: str
- key_points: List[str]
+ key_points: List[KeyPoint] # 注意:旧版是 List[str],解析层做了兼容
material_ids: List[str]
class Outline(TypedDict):