feat(ai-writing): 大纲先行确认 + 新文章类型识别 + 参考文献 + 编辑器修复

- 写作配置 Q&A 改为「先流式生成大纲并多轮确认 → 再确认意图」,放宽轮数上限
- 意图识别出列表外的新文章类型时带上类型名+简介(仅本次写作临时使用)
- 成稿支持参考文献:正文 [n] 角标 + 文末按出现顺序的「参考文献」列表(表单开关,默认开)
- 流式接取未闭合 <think> 思考内容,首 token 前加「正在整理…」占位
- 修复右侧编辑器输入空格只出一次、光标跳字(避免自激回灌 setContent 重置光标)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-23 19:07:03 +08:00
1 parent cd33aaeded
commit b8c78ba06e
26 files changed
+1273 -352

No files matched your search

@@ -209,11 +209,17 @@ async def pause_draft_confirm(state: AIWritingState, config: RunnableConfig) ->
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": f"按用户意见重写({notes[:30]})"}],
}
# to_editor
# to_editor:用户可能在「您的修改意见」里填了关注点再点「让编辑继续审核」,
# 把它带给编辑节点,让三维度审查重点核查用户在意的方面(不再被丢弃)。
editor_notes = (user_input.get("userRevisionNotes") or "").strip()
msg = (
f"提交编辑审核(已附带你的修改意见:{editor_notes[:30]})"
if editor_notes else "提交编辑审核"
)
return {
"last_user_intervention": user_input,
"last_user_intervention": {**user_input, "_editor_notes": editor_notes},
"status": "reviewing",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "提交编辑审核"}],
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
}
@@ -10,6 +10,7 @@ from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, ReviewResult, ReviewIssue
from ..prompts.editor_prompts import (
EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER,
USER_FOCUS_BLOCK,
)
from ._agent_runner import (
EDITOR_AGENT_ID,
@@ -34,6 +35,19 @@ logger = logging.getLogger(__name__)
_DEFAULT_PASS_THRESHOLD = 75
def _build_user_focus(state: AIWritingState) -> str:
"""从干预数据取用户在「编辑审核」时附带的修改意见,拼成注入审查提示词的关注块。
仅当用户点「让编辑继续审核」且填了意见(graph.py `to_editor` 分支 stash 的
``_editor_notes``)时返回非空块;否则返回空串,三维度审查保持原样。
"""
intervention = state.get("last_user_intervention") or {}
notes = (intervention.get("_editor_notes") or "").strip()
if not notes:
return ""
return USER_FOCUS_BLOCK.format(notes=notes)
def _materials_summary(pkg: dict) -> str:
lines = []
for m in (pkg.get("materials") or [])[:6]:
@@ -129,10 +143,13 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
ai_writing_config = get_ai_writing_config()
pass_threshold = int(ai_writing_config.get("pass_threshold", _DEFAULT_PASS_THRESHOLD))
# 用户在草稿确认时附带的修改意见 → 注入三维度审查,让编辑重点核查(空则不影响原审查)
user_focus = _build_user_focus(state)
REVIEW_TASKS = [
("fact", FACT_REVIEW_USER.format(materials_summary=mats, article=article)),
("logic", LOGIC_REVIEW_USER.format(article=article)),
("language", LANGUAGE_REVIEW_USER.format(article=article)),
("fact", FACT_REVIEW_USER.format(materials_summary=mats, article=article, user_focus=user_focus)),
("logic", LOGIC_REVIEW_USER.format(article=article, user_focus=user_focus)),
("language", LANGUAGE_REVIEW_USER.format(article=article, user_focus=user_focus)),
]
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.editor):
@@ -151,7 +151,9 @@ def _resolve_intent_fields(
llm_type = raw_type if raw_type in valid_keys else fallback_type
preconfigured_type = (state.get("article_type") or "").strip()
if preconfigured_type in valid_keys:
if preconfigured_type:
# 用户/配置助手已明确指定文章类型(含**列表外的自定义/新类型**)→ 原样尊重,
# 不再因不在默认表里被丢弃回退成默认(否则自定义/新类型在写作时会被还原成默认)。
final_article_type = preconfigured_type
else:
final_article_type = llm_type
@@ -1,6 +1,7 @@
"""作家节点 Phase 2:按大纲逐段写正文,支持流式 chunk 输出。"""
from __future__ import annotations
import json
import logging
import re
@@ -9,9 +10,16 @@ from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, DraftArticle
from ..prompts.writer_prompts import (
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER,
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER, DRAFT_COMPRESS_USER,
OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
)
# 复用大纲节点的解析/素材摘要:修订模式下先据意见更新大纲(含标题),再重写正文。
# writer_outline 不反向 import 本模块,无循环。
from .writer_outline import (
_parse_outline as _revise_parse_outline,
_materials_summary as _revise_materials_summary,
)
from ._agent_runner import (
WRITER_AGENT_ID,
WritingAgentUnavailable,
@@ -27,13 +35,116 @@ from ._model_fallback import (
emit_model_switch_event,
fallback_chain,
)
from ._utils import resolve_writing_model_name, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
from ._utils import resolve_writing_model_name, _content_to_text, count_words, llm_json, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
# 章节正文里的 Markdown 标题行(允许前导空白) — 分组捕获缩进 + 井号串
_HEADING_PREFIX_RE = re.compile(r"^([ \t]*)(#{1,6})[ \t]+", re.MULTILINE)
# ── 字数控制 ────────────────────────────────────────────────────────────────
# 全文允许偏离目标字数的误差带(用户要求「误差不超过 100 字」):初稿装配后双向收敛
# 到 [target-tol, target+tol] 内,太短就扩写、太长就压缩。
_WORD_COUNT_TOLERANCE = 100
# 单节字数下限:均分/压缩时给章节兜个底,避免某节被压成一两句话。
_SECTION_MIN_WORDS = 80
# 收敛回合上限:每回合调一节(按相对应得字数偏差最大的节优先)。放宽到 8,让大幅超量
# (需逐节压多节)也能压到位;命中容差或所有节都压不动即提前停。
_MAX_REBALANCE_ROUNDS = 8
def _allocate_section_words(word_count_target: int, n_sections: int) -> int:
"""把总目标字数均分到各章节。
历史实现按 1.1 过量系数放大以补偿模型「写不够」,但实测模型更常「写太多」
(800 字写成 2000)。这里去掉放大、按目标如实均分,只留一个保底下限防止单节退化;
初稿超量/欠量统一交给装配后的双向收敛阶段纠正,确保全文落在误差带内。
"""
n = max(n_sections, 1)
return max(_SECTION_MIN_WORDS, round(word_count_target / n))
def _section_word_range(per_section: int) -> tuple[int, int]:
"""单节目标对应的「硬上下限」,喂给模型卡字数。
关键是给出**上限**——旧提示词只说「不少于 N 字」,模型逐节超额累加才把 800 写成 2000。
"""
return int(per_section * 0.85), int(per_section * 1.2)
def _pick_rebalance(
counts: list[int], fair: int, target: int, tol: int, skip: set[int] | tuple = ()
) -> tuple[int, int] | None:
"""决定下一步调整哪一节、调到多少字;全文已在容差内或无可调项则返回 None。
返回 ``(idx, sec_target)``:
- 全文偏长 → 选「超应得份额最多」且未跳过的节,**压到它的应得份额**(可达目标,不强求
一节吃下全部缺口;各超额节轮流压到份额,累计即收敛到目标)。没有超份额的节时,退而
把最长的一节按缺口压一压。
- 全文偏短 → 选「最欠份额」的节,把整体缺口加到它头上扩写(扩写对模型更容易)。
``skip`` 是本轮已尝试但「压不动 / 没改善」的章节集合,跳过它们换下一节,避免一节压不动
就放弃整个收敛。配合调用方「确有改善才采用」的护栏,保证单调收敛、自然终止。
"""
if not counts:
return None
total = sum(counts)
delta = target - total # >0 需补,<0 需删
if abs(delta) <= tol:
return None
pool = [k for k in range(len(counts)) if k not in skip]
if not pool:
return None
if delta < 0:
over = [k for k in pool if counts[k] > fair]
if over:
idx = max(over, key=lambda k: counts[k] - fair)
return idx, max(_SECTION_MIN_WORDS, fair)
# 各节都不高于均分却仍超目标(节数偏多 / 下限抬高)→ 压最长的一节
idx = max(pool, key=lambda k: counts[k])
return idx, max(_SECTION_MIN_WORDS, counts[idx] + delta)
under = [k for k in pool if counts[k] < fair]
target_pool = under or pool
idx = min(target_pool, key=lambda k: counts[k] - fair)
return idx, counts[idx] + delta
# 触发「先改大纲再重写正文」的修订动作(直接按用户意见改 / 采纳编辑意见 / 自定义修订)
_REVISE_ACTIONS = {"user_revise", "accept_review", "custom_revise"}
async def _revise_outline_with_notes(
state: AIWritingState, config: RunnableConfig, outline: dict, feedback: str
) -> dict:
"""修订模式下,先按用户/编辑意见更新大纲(标题/结构),再据此重写正文。
章节标题、文章标题都由大纲注入,正文生成本身不产出标题——所以「把第一章标题改成…」
「加一节 / 删一节 / 调整顺序」这类指令必须先落到大纲,成稿里才看得到变化(否则只是
正文被重写、标题仍沿用旧大纲)。复用 OUTLINE_REVISE 提示词:它只改用户明确提到的
部分,纯正文类意见(如「第二段数据来源不清晰」)不会误动结构。任何异常都保留原大纲。
"""
pkg = state.get("material_package") or {}
model_name = (state.get("model_name") or "").strip() or None
try:
result = await llm_json(
OUTLINE_REVISE_SYSTEM,
OUTLINE_REVISE_USER.format(
user_intent=state.get("user_intent", ""),
article_type=state.get("article_type", "analysis"),
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
current_outline=json.dumps(outline, ensure_ascii=False, indent=2),
feedback=feedback,
materials_summary=_revise_materials_summary(pkg),
),
outline, # 解析失败 → 回退原大纲
config,
model_name=model_name,
)
return _revise_parse_outline(result)
except Exception as e:
logger.warning("draft revise: outline update failed, keep original outline: %s", e)
return outline
def _normalize_section_headings(text: str) -> str:
"""把章节正文里的 Markdown 标题层级归一到允许的两级:### / ####。
@@ -117,19 +228,112 @@ def _looks_like_refusal(text: str) -> bool:
return any(marker in text for marker in _REFUSAL_MARKERS)
def _get_section_materials(material_ids: list[str], pkg: dict) -> str:
def _build_citation_map(pkg: dict) -> dict[str, int]:
"""material_id → 1-based 序号(按 package 顺序),写作时作为角标 [n] 的稳定编号。
成稿装配后再按「正文出现顺序」重排成连续 [1][2][3](见 ``_append_references``)。
"""
return {m["id"]: i + 1 for i, m in enumerate(pkg.get("materials", [])) if m.get("id")}
def _get_section_materials(material_ids: list[str], pkg: dict, cite_map: dict[str, int] | None = None) -> str:
"""渲染本节可用素材喂给作家。
``cite_map`` 非空(开启参考文献)时,每条素材前缀用其**全局引用编号** ``[n]``,作家据此在
正文里打角标;为空时沿用旧行为(前缀素材 id,仅作辨识、不要求引用)。
"""
mat_map = {m["id"]: m for m in pkg.get("materials", [])}
def _prefix(mid: str) -> str:
if cite_map and mid in cite_map:
return f"[{cite_map[mid]}]"
return f"[{mid}]"
lines = []
for mid in material_ids:
if mid in mat_map:
m = mat_map[mid]
lines.append(f"[{mid}] {m['title']}: {m['content'][:300]}")
lines.append(f"{_prefix(mid)} {m['title']}: {m['content'][:300]}")
if not lines:
for m in pkg.get("materials", [])[:2]:
lines.append(f"[{m['id']}] {m['title']}: {m['content'][:300]}")
lines.append(f"{_prefix(m['id'])} {m['title']}: {m['content'][:300]}")
return "\n".join(lines) if lines else "(无相关素材)"
# 开启参考文献时,追加到每节写作指令末尾:要求作家在引用素材处打角标。
_CITATION_DIRECTIVE = (
"\n\n【引用要求】上面每条素材前的 [n] 是它的编号。"
"当你在正文里用到某条素材的事实/数据/观点时,在该句句末加上对应角标,如 [1] 或 [1][3];"
"只能使用确实给出的素材编号,不要编造编号,不要给通用常识加角标。"
"不要在本节末尾自行罗列参考文献——文末的「参考文献」列表由系统统一生成。"
)
_CITATION_TOKEN_RE = re.compile(r"\[(\d{1,3})\]")
def _format_reference_entry(m: dict) -> str:
"""单条参考文献的展示文本:标题. 来源. 日期. url(缺项自动省略)。"""
title = (m.get("title") or "").strip() or "(无标题)"
parts = [title]
source = (m.get("source") or "").strip()
if source:
parts.append(source)
date = (m.get("published_at") or "").strip()
if date:
parts.append(date)
entry = ". ".join(parts)
url = (m.get("url") or "").strip()
if url:
entry = f"{entry}. {url}"
return entry
def _append_references(markdown: str, pkg: dict, cite_map: dict[str, int]) -> str:
"""把正文里的引用角标按「出现顺序」重排为连续 [1][2][3],并在文末追加「参考文献」列表。
只收录正文真正引用到(且编号有效)的素材,列表顺序与正文出现顺序一致——像论文一样。
没有任何有效引用时原样返回(不加空列表)。
"""
num_to_mat: dict[int, dict] = {}
for m in pkg.get("materials", []):
n = cite_map.get(m.get("id"))
if n:
num_to_mat[n] = m
if not num_to_mat:
return markdown
# 1) 按正文出现顺序收集被引用且有效的原始编号
order: list[int] = []
seen: set[int] = set()
for match in _CITATION_TOKEN_RE.finditer(markdown):
k = int(match.group(1))
if k in num_to_mat and k not in seen:
seen.add(k)
order.append(k)
if not order:
return markdown
# 2) 原始编号 → 连续新编号(出现先后);逐 match 独立映射,避免顺序替换串号
renumber = {old: new for new, old in enumerate(order, start=1)}
def _sub(match: re.Match) -> str:
k = int(match.group(1))
return f"[{renumber[k]}]" if k in renumber else match.group(0)
body = _CITATION_TOKEN_RE.sub(_sub, markdown)
# 3) 文末参考文献列表(按新编号顺序)。
# 每条之间用**空行**分隔,使其各成独立段落——否则 Markdown 会把单换行的多行并成一段,
# 所有文献会挤成一坨(见排版反馈)。与正文一致沿用 [n] 角标,不再叠加列表序号。
entries = [
f"[{renumber[old]}] {_format_reference_entry(num_to_mat[old])}"
for old in order
]
references_md = "## 参考文献\n\n" + "\n\n".join(entries)
return f"{body}\n\n{references_md}"
async def _save_draft_to_repo(config: RunnableConfig, draft: DraftArticle) -> None:
"""把 current_draft 异步落到业务表(best-effort,写失败不影响流程)。
@@ -171,6 +375,10 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
intervention = state.get("last_user_intervention") or {}
revision_count = state.get("revision_count", 0)
# 参考文献:开启且有素材时,写作给素材编号 + 要求作家打角标 [n],成稿装配后统一生成文献列表。
add_references = bool(state.get("add_references", False)) and bool(pkg.get("materials"))
cite_map = _build_citation_map(pkg) if add_references else None
# 写作模式;用户在「素材不足求助」里选「按通用知识续写」的章节强制走宽松
writing_mode = state.get("writing_mode", "loose")
loose_titles: set[str] = set(intervention.get("_loose_sections") or [])
@@ -198,11 +406,23 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
if parts:
revision_notes = "\n修改要求:\n" + "\n".join(parts)
# 修订模式:先把上面这条意见落实到大纲(标题/结构),再据更新后的大纲逐节重写。
# 章节标题、文章标题都由大纲注入、正文生成本身不产出标题,所以「改第一章标题」「加一节/
# 删一节」这类指令必须先改大纲才能在成稿里生效(否则只是正文被重写、标题仍是旧的)。
outline_revised = False
if intervention.get("action") in _REVISE_ACTIONS and revision_notes.strip():
revised_outline = await _revise_outline_with_notes(state, config, outline, revision_notes.strip())
if revised_outline and revised_outline != outline:
outline = revised_outline
outline_revised = True
word_count_target = state.get("word_count_target", 800)
sections = outline.get("sections", [])
n_sections = max(len(sections), 1)
# 单节硬下限:总目标 / 节数,乘 1.1 过量系数补偿模型对短目标的天然欠量;单节至少 150 字
words_per_section = max(150, int(word_count_target * 1.1 / n_sections))
# 按目标如实均分到各节(不再放大),并给模型一个硬上下限区间卡字数;
# 装配后再双向收敛把全文夹逼进误差带。
words_per_section = _allocate_section_words(word_count_target, n_sections)
sec_min_words, sec_max_words = _section_word_range(words_per_section)
article_title = outline.get("title", "文章")
# 兼容后端 snake_case 与前端编辑后回传的 camelCase
@@ -265,7 +485,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
key_points = section.get("key_points") or section.get("keyPoints") or []
mat_ids = section.get("material_ids") or section.get("materialIds") or []
section_materials = _get_section_materials(mat_ids, pkg)
section_materials = _get_section_materials(mat_ids, pkg, cite_map)
# 三级结构:把每个 KeyPoint 展开成「小节标题 + 缩进子要点」喂给作家。
# 兼容旧 session 里 key_points 为 List[str] 的情况(走相同 helper)。
key_points_str = _render_key_points_for_prompt(key_points)
@@ -289,7 +509,12 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
revision_section=revision_notes,
mode_section=mode_section,
target_words=words_per_section,
min_words=sec_min_words,
max_words=sec_max_words,
)
# 开启参考文献时,追加角标引用要求(两条路径——内置 agent 与旧路径——共用 user_msg)。
if add_references:
user_msg += _CITATION_DIRECTIVE
section_buf = "" # 只累积正文(非思考)用于拼接 Markdown
section_succeeded = False
@@ -440,61 +665,95 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
],
}
def _assemble() -> str:
def _assemble(bufs: list[str]) -> str:
# 章节正文里的标题统一归一到 ###(要点小标题),章节标题用 ##,文章标题用 #
body = "\n\n".join(
f"## {t}\n\n{_normalize_section_headings(b)}"
for t, b in zip(section_titles, section_bufs)
for t, b in zip(section_titles, bufs)
)
return f"# {article_title}\n\n{body}"
full_markdown = _assemble()
async def _rewrite_section(idx: int, sec_target: int, *, shrink: bool) -> str:
"""重写第 idx 节使其趋近 sec_target 字:shrink=True 走压缩、否则扩写(单轮,非流式)。
兜底优化,不再做模型链容错:拿当前已成功的模型试一次即可,失败由调用方保留原文。
"""
sec = sections[idx]
tmpl = DRAFT_COMPRESS_USER if shrink else DRAFT_EXPAND_USER
msg = tmpl.format(
article_title=article_title,
section_title=section_titles[idx],
key_points=_render_key_points_for_prompt(
sec.get("key_points") or sec.get("keyPoints") or []
),
section_materials=_get_section_materials(
sec.get("material_ids") or sec.get("materialIds") or [], pkg, cite_map
),
existing_text=section_bufs[idx],
target_words=sec_target,
)
# 扩写/压缩时保留正文里已有的引用角标 [n],避免重排后文献缺失。
if add_references:
msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。"
if use_agent:
return (await stream_writing_agent(
agent_id=WRITER_AGENT_ID,
user_payload=msg,
state=state,
config=config,
on_text=None,
)).strip()
rewrite_model = create_chat_model(name=current_model_name) if current_model_name else create_chat_model()
resp = await rewrite_model.ainvoke([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=msg),
])
parser = ThinkTagParser()
return "".join(
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking
).strip()
full_markdown = _assemble(section_bufs)
word_count = count_words(full_markdown)
# 扩写兜底:总字数不足目标 90% 时,重写并扩充最短的章节(单轮,非流式)
if section_bufs and word_count < word_count_target * 0.9:
idx = min(range(len(section_bufs)), key=lambda k: count_words(section_bufs[k]))
target_sec = sections[idx]
expand_target = count_words(section_bufs[idx]) + (word_count_target - word_count)
# 双向收敛兜底:把全文字数夹逼进 [target±tol]。太短就扩写最欠的节、太长就压缩最超的节,
# 每回合只动一节,且仅在「确实更接近目标」时才采用。某节压不动 / 没改善 → 记入 skip 换
# 下一节继续(不再一节失败就放弃整个收敛,否则弱模型一次压缩失败就留下超量稿)。
rounds = 0
skip: set[int] = set()
while section_bufs and rounds < _MAX_REBALANCE_ROUNDS:
counts = [count_words(b) for b in section_bufs]
pick = _pick_rebalance(counts, words_per_section, word_count_target, _WORD_COUNT_TOLERANCE, skip)
if pick is None:
break
rounds += 1
idx, sec_target = pick
shrink = sec_target < counts[idx]
try:
expand_msg = DRAFT_EXPAND_USER.format(
article_title=article_title,
section_title=section_titles[idx],
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
),
existing_text=section_bufs[idx],
target_words=expand_target,
)
if use_agent:
expanded = (await stream_writing_agent(
agent_id=WRITER_AGENT_ID,
user_payload=expand_msg,
state=state,
config=config,
on_text=None,
)).strip()
else:
# 扩写是兜底优化,不再做模型链兜底:拿当前已成功的模型试一次即可,失败就保留原文。
expand_model = create_chat_model(name=current_model_name) if current_model_name else create_chat_model()
resp = await expand_model.ainvoke([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=expand_msg),
])
parser = ThinkTagParser()
expanded = "".join(
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking
).strip()
# 仅在扩写确实更长时才采用,避免越改越短
if count_words(expanded) > count_words(section_bufs[idx]):
section_bufs[idx] = expanded
full_markdown = _assemble()
word_count = count_words(full_markdown)
new_text = await _rewrite_section(idx, sec_target, shrink=shrink)
except Exception as e:
logger.warning("draft expand failed for section [%s]: %s", section_titles[idx], e)
logger.warning("draft rebalance failed for section [%s]: %s", section_titles[idx], e)
skip.add(idx)
continue
if not new_text:
skip.add(idx)
continue
candidate = list(section_bufs)
candidate[idx] = new_text
new_md = _assemble(candidate)
new_wc = count_words(new_md)
if abs(new_wc - word_count_target) < abs(word_count - word_count_target):
section_bufs = candidate
full_markdown = new_md
word_count = new_wc
skip.discard(idx) # 改善了,后续若仍最超可再压
else:
skip.add(idx) # 这一节压不动 / 反而更差,跳过它,换下一节
# 参考文献:在全文(含字数收敛)定稿后统一处理——把正文角标按出现顺序重排为连续 [1][2][3],
# 并在文末追加「参考文献」列表(只收录被引用到的素材)。word_count 保持正文字数、不计文献列表。
if add_references and cite_map:
full_markdown = _append_references(full_markdown, pkg, cite_map)
draft = DraftArticle(
title=outline.get("title", "文章"),
@@ -513,6 +772,13 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
notices: list[dict] = []
if model_default_notice:
notices.append(model_default_notice)
if outline_revised:
notices.append({
"type": "notice",
"agent_name": agent_label,
"title": "已按意见调整大纲",
"detail": "已根据你的修改意见更新大纲(如标题/结构),并据此重写正文。",
})
if section_errors:
notices.append({
"type": "notice",
@@ -521,7 +787,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
"detail": "部分章节生成失败,对应段落已占位。原因:" + ";".join(section_errors[:3]),
})
return {
updates: dict = {
"current_draft": draft,
"drafts": [draft],
"status": "awaiting_draft_confirm",
@@ -539,3 +805,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
},
],
}
# 大纲被意见改过 → 同步回 state,使后续修订/确认基于更新后的大纲(标题不再回退)
if outline_revised:
updates["current_outline"] = outline
return updates
@@ -271,9 +271,14 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
logger.warning("outliner builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
# 新(列表外)文章类型时附上配置助手识别的一句话特征,让大纲生成体现该类型侧重;
# 命中已有类型时 detail 为空,article_type 维持原样。
_article_type = state.get("article_type", "analysis")
_article_type_detail = (state.get("article_type_detail") or "").strip()
article_type_text = f"{_article_type}({_article_type_detail})" if _article_type_detail else _article_type
fmt_args = dict(
user_intent=state["user_intent"],
article_type=state.get("article_type", "analysis"),
article_type=article_type_text,
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
materials_summary=_materials_summary(pkg),
@@ -24,8 +24,17 @@ EDITOR_SYSTEM = """你是一名资深主编(编辑),标准极高,要求
]
}"""
# 用户在提交编辑审核时附带的修改意见 / 关注点(无则为空串,不注入)。
# 放在「判断标准」之后、待审文章之前,提醒编辑在本维度重点核查用户在意的方面。
USER_FOCUS_BLOCK = """
【用户特别关注(高优先级)】
用户在提交审核时提出了以下修改意见 / 关注点,请在本维度审查时**重点核查**这些方面;
若发现文章未妥善处理,必须在 issues 中明确列出对应问题并给出针对性的 suggestion:
{notes}"""
FACT_REVIEW_USER = """请审查以下文章的【事实准确性】维度。
判断标准:引用的数据/事实是否有素材支撑,是否有捏造或夸大。
判断标准:引用的数据/事实是否有素材支撑,是否有捏造或夸大。{user_focus}
参考素材:
{materials_summary}
@@ -36,7 +45,7 @@ FACT_REVIEW_USER = """请审查以下文章的【事实准确性】维度。
只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):"""
LOGIC_REVIEW_USER = """请审查以下文章的【逻辑结构】维度。
判断标准:论点是否清晰,论据是否充分,段落是否连贯,结构是否合理。
判断标准:论点是否清晰,论据是否充分,段落是否连贯,结构是否合理。{user_focus}
待审文章:
{article}
@@ -44,7 +53,7 @@ LOGIC_REVIEW_USER = """请审查以下文章的【逻辑结构】维度。
只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):"""
LANGUAGE_REVIEW_USER = """请审查以下文章的【语言文字】维度。
判断标准:语言是否流畅,用词是否准确,有无错别字或病句。
判断标准:语言是否流畅,用词是否准确,有无错别字或病句。{user_focus}
待审文章:
{article}
@@ -208,7 +208,8 @@ DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
- 直接返回这一段的内容(含 `### 小节标题` + 概述段 + `#### 子要点标题` + 各自正文),不要 JSON,不要解释。
- 不要写"以下是正文""本段内容如下"之类的开场白。
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
- 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
- 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理。
- 【字数纪律,硬性要求】严格遵守用户给定的字数区间:既不要明显写不够,更不要超出上限。靠控制论述详略来卡字数,不要为凑字数注水,也不要为收尾而草草带过。"""
# ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ───────────────────
@@ -240,14 +241,14 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
相关素材:
{section_materials}
{revision_section}{mode_section}
请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。
请写出「{section_title}」这一章节的内容,目标约 {target_words} 字,并**严格控制在 {min_words}~{max_words} 字之间**——不要明显少于下限,更绝不要超过上限。
按下面的结构两层展开写:
- 每个主要 bullet(顶格)→ `### 小节标题`,标题下先写 2-3 句概述/引入正文。
- 该小节的每个缩进 bullet(子要点)→ `#### 子要点标题`,标题下展开 3-5 句具体论述。
- 没有缩进 bullet 的小节,直接在 ### 标题下连贯成段论述,不出现 ####。
论述充分、层次清晰、内容充实。"""
论述层次清晰、内容充实,但务必在上述字数上限内完成:宁可精炼也不要为凑字数注水。"""
DRAFT_EXPAND_USER = """文章标题:{article_title}
当前章节:{section_title}
@@ -266,3 +267,21 @@ DRAFT_EXPAND_USER = """文章标题:{article_title}
- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
- 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
DRAFT_COMPRESS_USER = """文章标题:{article_title}
当前章节:{section_title}
章节内的小节与子要点(层级 bullet 列表):
{key_points}
相关素材:
{section_materials}
这一章节已有内容如下:
{existing_text}
以上内容篇幅明显偏长。请在保留原意、关键事实和「### 小节 / #### 子要点」两级标题结构的基础上**大幅精简**改写这一章节,使内容**压到 {target_words} 字以内**——这是硬性上限,宁可删减细节也绝不能超过。
要求:
- 这是「压缩」任务,不是「重写扩充」:必须比原文明显更短。大胆删减冗余表述、合并重复论点、砍掉次要细节与铺陈,只保留关键事实、数据与核心结论。
- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
- 直接返回精简后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
@@ -129,12 +129,17 @@ class AIWritingState(TypedDict):
# 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。
user_outline_raw: str
article_type: str
# 当 article_type 是「已有类型库里没有」的新类型时,配置助手识别出的一句话特征说明。
# 仅本次写作临时使用(不入库):在大纲生成时作为 hint 附在类型后,影响全文结构与侧重。
article_type_detail: str
target_audience: str
word_count_target: int
# 素材检索关键词数量(用户指定,1~10,默认 6)
keyword_count: int
# 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose)
writing_mode: str
# 是否在成稿里加参考文献:正文引用素材处加角标 [n],文末按出现顺序生成「参考文献」列表(像论文)。
add_references: bool
# 检索类型:general=通用检索(按素材收集专家 agent 配置的技能编排,默认)/
# notebook=我的空间(仅笔记检索)。旧值 knowledge_base / skill 由 researcher
# 节点按 general 兼容处理。
@@ -27,7 +27,9 @@ from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
# 写作配置 Q&A:意图澄清最多几轮(一条用户消息 = 一轮),到顶强制提交配置卡。
WRITING_SETUP_MAX_ROUNDS = 3
# 写作配置 Q&A 现在「先生成大纲并与用户多轮确认 → 再提交配置」,需要更多来回,故上限放宽。
# 仍是兜底安全阀:到顶才用 tool_choice 强制提交,正常流程由用户确认大纲后自然提交。
WRITING_SETUP_MAX_ROUNDS = 8
def _get_runtime_config(config: RunnableConfig) -> dict:
@@ -1128,29 +1128,40 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
type_lines = "\n".join(
f" - `{t.get('key', '')}`:{t.get('label', '')}" for t in article_types if t.get("key")
)
type_block = f"【可选文章类型】article_type 只能填下列 key 之一:\n{type_lines}"
type_block = (
"【文章类型】优先从下列已有类型里选,命中就把它的 key 填进 `article_type`:\n"
f"{type_lines}\n"
"若上述类型**都不贴切**,可以**自拟一个新的文章类型**:`article_type` 填一个简短的中文类型名"
"(如「政策解读」「人物特写」「复盘总结」),并在 `article_type_detail` 里用一句话说明该类型的"
"写作侧重/特征。能用已有类型就不要新造,确实不贴切才新建。"
)
else:
type_block = "【文章类型】若不确定 article_type,可留空,交给用户在表单里选。"
type_block = (
"【文章类型】根据意图判断文章类型:`article_type` 填一个简短的中文类型名,并在 "
"`article_type_detail` 里用一句话说明其写作侧重;不确定可留空,交给用户在表单里选。"
)
return f"""你是「AI 写作」功能的**配置助手**。你的唯一职责:通过简短对话把用户的写作需求聊清楚,然后调用 `setup_writing` 工具把写作配置提交给用户确认。提交后由用户在表单里应用、再点「开始写作」。
return f"""你是「AI 写作」功能的**配置助手**。你的职责分两段:① 先与用户把写作需求聊清楚,**先生成并与用户确认一份大纲**;② 待用户对大纲满意后,再调用 `setup_writing` 工具提交写作配置(含已确认的大纲),交用户开始写作。
【铁律——最高优先级】
- 你**绝对不撰写**文章正文、分析内容、大纲内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只把它当作"要配置的写作目标"**,绝不替用户动笔、绝不直接完成这篇写作。
- 你**绝对不撰写**文章正文、分析内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只产出「大纲」(仅结构与标题)与「写作配置」**,绝不替用户动笔、绝不直接完成这篇写作。
- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。
- 不要在回复里输出文章段落、要点清单或大纲(用户主动粘贴的大纲除外——原样放进 `outline` 参数)。
- `setup_writing` **只能在用户明确确认大纲之后**才调用;在那之前绝不调用它。
【工作流程】(与用户交互确认,但最多 3 轮内必须提交)
1. 读用户输入。当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 向用户提问——**优先给出 `options` 选项**让用户点选(例如目标字数给「800 / 1500 / 3000」,读者给「普通读者 / 行业专家 / 政策决策者」)。一次可就 1-2 个独立的关键项各发一个 ask_clarification。
2. 与用户的澄清来回**最多 2 次**。一旦关键信息明确、或已经问过 2 次,就**立即进入第 3 步提交**,缺失字段用合理默认值,绝不无休止追问。
3. 调用 `setup_writing` 提交配置:
【工作流程】
1. **聊清需求**:读用户输入,当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 提问——**优先给出 `options` 选项**让用户点选(如目标字数「800 / 1500 / 3000」、读者「普通读者 / 行业专家 / 政策决策者」)。澄清**最多 2 次**,信息够了就进入下一步,不要无休止追问。
2. **生成大纲(先于确认意图)**:信息足够后,直接以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与目标字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。用户若只是补充信息也照常更新大纲再确认。
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:
- `user_intent`:精炼成一句话的写作目标(必填)。
- `article_type`:见下方可选类型;不确定就留空。
- `article_type`:见下方【文章类型】——优先选已有类型的 key;都不贴切才自拟一个新的中文类型名。
- `article_type_detail`:**仅当 article_type 是新类型时**,用一句话说明该类型的写作侧重/特征;命中已有类型则留空。
- `word_count_target`:正整数;用户没明说就给合理默认(如 800)。
- `target_audience`:如「普通读者」「行业专家」;没提就填「普通读者」。
- `writing_mode`:用户强调「严格依据素材/不要编造」→ `strict`,否则 `loose`。
- `material_source`:**默认 `knowledge_base`(知识库)**;只有当用户明确要求用「技能/技能检索」时才填 `skill`,否则一律 `knowledge_base`。
- `outline`:用户提供的大纲原文(保留其层级与标题),否则留空。
4. 调用 `setup_writing` 前,用**一句话**说明你将提交的配置要点;调用之后不要再输出任何文章内容。
- `outline`:**填入刚刚与用户确认的那份大纲全文**(保留其层级与标题)。
调用前用**一句话**说明将提交的配置要点;调用之后不再输出任何内容。
{type_block}
"""
@@ -74,6 +74,8 @@ class SetupWritingApprovalMiddleware(AgentMiddleware[SetupWritingApprovalMiddlew
return {
"user_intent": str(args.get("user_intent") or "").strip(),
"article_type": str(args.get("article_type") or "").strip(),
# 新(列表外)文章类型的一句话特征说明;命中已有类型时为空。
"article_type_detail": str(args.get("article_type_detail") or "").strip(),
"word_count_target": self._coerce_word_count(args.get("word_count_target")),
"target_audience": str(args.get("target_audience") or "").strip(),
"writing_mode": str(args.get("writing_mode") or "").strip(),
@@ -28,24 +28,30 @@ def setup_writing(
writing_mode: str | None = None,
material_source: str | None = None,
outline: str | None = None,
article_type_detail: str | None = None,
) -> Command:
"""把与用户聊清的写作配置提交给前端表单(不直接开始写作)。
Args:
user_intent: 用户的写作意图(精炼成一句话的写作目标)。
article_type: 文章类型的 key(须是系统给定的合法类型之一;不确定可留空)。
article_type: 文章类型。优先填系统给定的合法类型 key;若已有类型都不贴切,可**自拟一个新的
文章类型**——此时填一个简短的中文类型名(如「政策解读」「人物特写」),并在
``article_type_detail`` 里说明该类型特征。不确定可留空。
word_count_target: 目标字数(正整数)。
target_audience: 目标读者(如「行业专家」「普通读者」)。
writing_mode: 写作模式,'strict'(严格依据素材)或 'loose'(可结合通用知识)。
material_source: 素材来源,'knowledge_base'(知识库)或 'skill'(技能检索)。
outline: 若用户已提供大纲,原样填入其大纲文本(保留其层级与标题),否则留空。
outline: **已与用户确认的大纲全文**(保留其层级与标题)。本工具在用户确认大纲后调用,
故通常应带上这份大纲;用户未提供/未确认大纲时才留空。
article_type_detail: 仅当 ``article_type`` 是**新类型(不在已有列表里)**时填写:用一句话说明
这个类型的写作侧重/特征(如「以政策原文为锚、逐条拆解影响并给出执行建议」)。命中已有类型时留空。
"""
logger.info("[setup_writing] proposed writing config submitted (intercepted by approval middleware)")
return Command(
update={
"messages": [
ToolMessage(
content="写作配置已生成,请在右侧确认后应用到表单。",
content="写作配置已生成,请在右侧确认后应用到表单,或直接开始写作。",
tool_call_id=runtime.tool_call_id,
)
]