This commit is contained in:
fengxiangdebaozi committed 2026-05-26 23:20:58 +08:00
commit a501463ac7
6 files changed
+438 -74

No files matched your search

@@ -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 (
<div
draggable={!readOnly}
@@ -104,7 +123,9 @@ export function OutlineSection({
</span>
)}
<span className="text-muted-foreground/50 shrink-0">{section.keyPoints.length} 要点</span>
<span className="text-muted-foreground/50 shrink-0">
{keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''}
</span>
{!readOnly && onRemove && (
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
<X className="size-3" />
@@ -112,24 +133,151 @@ export function OutlineSection({
)}
</div>
{/* 要点列表 */}
{/* 小节列表 (Level 2) */}
<CollapseTransition open={expanded}>
<div className="px-8 pb-2.5 space-y-1 border-t">
{section.keyPoints.map((kp, i) => (
<div key={i} className="flex items-center gap-1.5 pt-1.5">
<span className="text-muted-foreground/40 shrink-0">•</span>
<div className="px-6 pb-2.5 pt-1 space-y-1.5 border-t">
{keyPoints.map((kp, i) => (
<KeyPointRow
key={i}
keyPoint={kp}
onChange={(updated) => updateKeyPoint(i, updated)}
onRemove={readOnly ? undefined : () => removeKeyPoint(i)}
readOnly={readOnly}
/>
))}
{!readOnly && (
<button
type="button"
onClick={addKeyPoint}
className="flex items-center gap-1 text-muted-foreground/60 hover:text-primary pt-0.5"
>
<Plus className="size-3" />
添加小节
</button>
)}
</div>
</CollapseTransition>
</div>
);
}
// ─── 小节行(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 (
<div className="rounded border border-border/40 bg-muted/10">
{/* 小节头 */}
<div className="flex items-center gap-1.5 px-2 py-1.5">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
aria-label="展开/收起子要点"
>
<ChevronRight
className={cn(
'size-3 text-muted-foreground/60 transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
</button>
<span className="text-purple-500/70 shrink-0">###</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => 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="小节标题"
/>
) : (
<span
className={cn('flex-1', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{keyPoint.pointTitle || <span className="text-muted-foreground italic">(未命名小节)</span>}
</span>
)}
<span className="text-muted-foreground/40 shrink-0 text-[10px]">
{keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'}
</span>
{!readOnly && onRemove && (
<button
type="button"
onClick={onRemove}
className="shrink-0 text-muted-foreground/40 hover:text-destructive"
aria-label="删除小节"
>
<X className="size-3" />
</button>
)}
</div>
{/* 子要点 (Level 3) — 每个子要点会变成正文里的 #### 标题 + 一段独立论述 */}
<CollapseTransition open={expanded}>
<div className="px-6 pb-1.5 space-y-0.5 border-t border-border/30 bg-background/40">
{keyPoint.subPoints.map((sp, i) => (
<div key={i} className="flex items-center gap-1.5 pt-1">
{/* 用 #### 标记替代 bullet,提示这是「四级标题」而非简单列表项 */}
<span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>
{readOnly ? (
<span className="flex-1 text-muted-foreground">{kp}</span>
<span className="flex-1 text-muted-foreground">{sp}</span>
) : (
<input
value={kp}
onChange={(e) => 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 && (
<button type="button" onClick={() => removeKeyPoint(i)} className="shrink-0 text-muted-foreground/30 hover:text-destructive">
<button
type="button"
onClick={() => removeSubPoint(i)}
className="shrink-0 text-muted-foreground/30 hover:text-destructive"
aria-label="删除子要点"
>
<X className="size-3" />
</button>
)}
@@ -138,11 +286,11 @@ export function OutlineSection({
{!readOnly && (
<button
type="button"
onClick={addKeyPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5"
onClick={addSubPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5 text-[11px]"
>
<Plus className="size-3" />
添加要点
添加子要点
</button>
)}
</div>
@@ -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<KeyPoint | string>;
materialIds: string[];
}
@@ -25,6 +40,32 @@ export interface AIOutline {
sections: OutlineSection[];
}
/**
* 把 keyPoints 单项规整成 KeyPoint。兼容三种输入:
* - 新版 KeyPoint object
* - 旧版 string (lift 为 { pointTitle: <str>, 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<string, unknown>;
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: [] };
}
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
/** 严格模式下素材不足、待用户决策的章节 */
@@ -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
@@ -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)
@@ -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。
- 章节/小节/子要点数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
【严禁思考与表态】(最高优先级)
- 禁止输出任何思考、分析、规划过程,禁止 <think></think> 标签及其中内容。
- 禁止输出任何对用户说的话;收到输入后立即、直接输出最终 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。
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
- 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
@@ -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):