From 8ca3fdec75968ad4cf5495ab9d5833dfd84b2d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 26 May 2026 21:10:27 +0800 Subject: [PATCH] =?UTF-8?q?ai=E5=86=99=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ai-writing/OutlineSection.tsx | 192 ++++++++++++++++-- .../src/open-canvas/types/ai-writing.ts | 43 +++- offline-backend-20260512/backend/config.yaml | 2 +- .../agents/ai_writing/nodes/writer_draft.py | 74 ++++++- .../agents/ai_writing/nodes/writer_outline.py | 72 ++++++- .../ai_writing/prompts/writer_prompts.py | 116 +++++++---- .../deerflow/agents/ai_writing/state.py | 15 +- 7 files changed, 439 insertions(+), 75 deletions(-) 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 && ( - )} @@ -138,11 +286,11 @@ export function OutlineSection({ {!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/config.yaml b/offline-backend-20260512/backend/config.yaml index 219b4b9e..2d82fb66 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -14,7 +14,7 @@ models: display_name: DeepSeek Chat use: langchain_openai:ChatOpenAI model: deepseek-chat - api_key: sk-e1c22b7931dc446f8b67e57878edd105 + api_key: sk-1520028668cb4cc388ed8315ff1e856e base_url: https://api.deepseek.com request_timeout: 600.0 max_retries: 2 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):