feat(ai写作): 意图解析顺便生成检索词 + 修复快捷模式

- 意图解析(intent_parser)与意图理解共用同一次 LLM 调用顺便生成检索词
  (结合意图 + 大纲覆盖各章节主题),存入 state.precomputed_keywords;
  researcher 首轮直接复用,省掉素材收集阶段单独的「生成检索词」LLM 往返。
  解析失败/为空时回退到 researcher 自己生成(优雅降级);re_search 仍重新生成。
  keyword_outline_section 提到 _utils 供两处共用。
- 修复快捷模式:之前被「大纲先行确认」改动拖累,先误成「问一堆问题」,再被我
  过度修正成「不生成大纲」。现修正为——快捷模式 = 用默认选项、不逐项澄清提问
  (make_lead_agent 在快捷模式下直接不挂 ask_clarification 工具,物理上问不了),
  但**仍生成并确认大纲**再提交;普通模式不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-24 18:32:10 +08:00
1 parent 49173d9c28
commit 304e1b637a
6 files changed
+109 -69

No files matched your search

@@ -40,6 +40,26 @@ def get_ai_writing_config() -> dict:
return {}
# 用户自带大纲时,检索词生成的大纲提示片段做长度截断,防止 token 溢出。
OUTLINE_KEYWORD_SNIPPET_CHARS = 1000
def keyword_outline_section(state: dict) -> str:
"""用户自带大纲时,给检索词生成附上大纲原文,让关键词覆盖各章节主题。
意图解析(intent_parser)与素材收集(researcher)的检索词生成共用,无大纲返回
空串(提示词里该行变空行,行为与原来一致)。
"""
raw = (state.get("user_outline_raw") or "").strip()
if not raw:
return ""
snippet = raw[:OUTLINE_KEYWORD_SNIPPET_CHARS]
return (
"用户已提供文章大纲,请结合写作意图,让检索词覆盖大纲涉及的各章节主题:\n"
f"{snippet}"
)
def safe_get_stream_writer():
"""返回一个 stream writer callable;在没有 LangGraph runtime 上下文时退化成 no-op。
@@ -18,6 +18,7 @@ from ._model_fallback import (
from ._utils import (
ThinkTagParser,
_content_to_text,
keyword_outline_section,
parse_json,
reasoning_delta,
safe_get_stream_writer,
@@ -75,17 +76,18 @@ _DEFAULT_ARTICLE_TYPES: list[dict] = [
]
_SYSTEM_TEMPLATE = """你是一名写作助手。
你的任务:分析用户的写作意图,提取结构化的写作配置。
你的任务:分析用户的写作意图,提取结构化的写作配置,并顺便提炼用于搜集素材的搜索关键词。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
- 必须使用英文双引号 "。
- article_type 只能是以下之一:{type_options}。
- keywords 为字符串数组:依据写作意图(及用户提供的大纲,若有)提炼的搜索关键词,要具体、可搜索,避免过于宽泛。
【完整示例】
输入意图:帮我写一篇面向行业专家的深度文章,分析今年新能源汽车出口为什么大涨,大概两千字
正确输出:
{{"article_type": "feature", "target_audience": "行业专家", "word_count_target": 2000, "refined_intent": "分析今年新能源汽车出口高速增长的原因"}}"""
{{"article_type": "feature", "target_audience": "行业专家", "word_count_target": 2000, "refined_intent": "分析今年新能源汽车出口高速增长的原因", "keywords": ["新能源汽车出口", "汽车出口数据", "新能源汽车海外市场", "电动车出口增长"]}}"""
_USER_TEMPLATE = """用户写作意图:{user_intent}
@@ -93,13 +95,14 @@ _USER_TEMPLATE = """用户写作意图:{user_intent}
- 文章类型(article_type):{preconfigured_article_type}
- 目标读者(target_audience):{preconfigured_audience}
- 目标字数(word_count_target):{preconfigured_word_count}
请输出以下 JSON,article_type / target_audience / word_count_target 必须与上述一致,只需根据用户意图精炼 refined_intent:
{outline_section}
请输出以下 JSON:article_type / target_audience / word_count_target 必须与上述一致;根据用户意图精炼 refined_intent;并提炼 {keyword_count} 个用于搜集素材的搜索关键词放入 keywords(数量不多不少、具体可搜索):
{{{{
"article_type": "{preconfigured_article_type}",
"target_audience": "{preconfigured_audience}",
"word_count_target": {preconfigured_word_count},
"refined_intent": "精炼后的写作意图(一句话)"
"refined_intent": "精炼后的写作意图(一句话)",
"keywords": ["检索词1", "检索词2"]
}}}}"""
@@ -204,11 +207,19 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
if not isinstance(preconfigured_wc, int) or preconfigured_wc <= 0:
preconfigured_wc = 800
# 检索词数量(用户表单指定,1~10,默认 6);意图解析顺便按此数量生成检索词,
# 供 researcher 首轮直接复用,省掉素材收集阶段单独的关键词 LLM 调用。
raw_kc = state.get("keyword_count")
keyword_count = raw_kc if isinstance(raw_kc, int) and 1 <= raw_kc <= 10 else 6
user = _USER_TEMPLATE.format(
user_intent=state["user_intent"],
preconfigured_article_type=fallback_type,
preconfigured_audience=fallback_result["target_audience"],
preconfigured_word_count=preconfigured_wc,
keyword_count=keyword_count,
# 用户自带大纲时附上大纲原文,让检索词覆盖各章节主题(与 researcher 共用同一逻辑)
outline_section=keyword_outline_section(state),
)
# 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON,
@@ -312,4 +323,13 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
if final_refined_intent != state["user_intent"]:
updates["user_intent"] = final_refined_intent
# 意图解析顺便生成的检索词:清洗后存入 state,researcher 首轮直接复用。
# 解析失败/为空时不写该字段(保持 None),researcher 回退到自己生成(优雅降级)。
raw_keywords = result.get("keywords")
if isinstance(raw_keywords, list):
precomputed = [s for kw in raw_keywords if (s := str(kw).strip())]
precomputed = list(dict.fromkeys(precomputed))
if precomputed:
updates["precomputed_keywords"] = precomputed
return updates
@@ -41,7 +41,7 @@ from ._agent_runner import (
use_builtin_agent,
)
from ._skill_agent import run_skill_search_agent, use_skill_search_agent
from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
from ._utils import get_ai_writing_config, keyword_outline_section, llm_json, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -59,24 +59,6 @@ _AGENT_KEYWORDS_USER = """任务:提取检索词
/no_think"""
# 用户自带大纲时,检索词生成的大纲提示片段做长度截断,防止 token 溢出。
_OUTLINE_KEYWORD_SNIPPET_CHARS = 1000
def _keyword_outline_section(state: AIWritingState) -> str:
"""用户自带大纲时,给检索词生成附上大纲原文,让关键词覆盖各章节主题。
无大纲返回空串(提示词里该行变空行,行为与原来一致)。
"""
raw = (state.get("user_outline_raw") or "").strip()
if not raw:
return ""
snippet = raw[:_OUTLINE_KEYWORD_SNIPPET_CHARS]
return (
"用户已提供文章大纲,请结合写作意图,让检索词覆盖大纲涉及的各章节主题:\n"
f"{snippet}"
)
_AGENT_RANK_USER = """任务:素材排序
写作意图:{user_intent}
@@ -507,44 +489,52 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
keywords = _clean_keywords(extra_keywords, intent)
_emit(f"追加检索 {len(keywords)} 个关键词…")
else:
_emit("正在生成检索词…")
# 用户自带大纲时,把大纲原文带进检索词生成,让关键词结合意图覆盖各章节主题
outline_section = _keyword_outline_section(state)
if use_agent:
kw_result = await run_writing_agent(
agent_id=RESEARCHER_AGENT_ID,
user_payload=_AGENT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
outline_section=outline_section,
),
marker=KEYWORDS_READY_MARKER,
fallback={"keywords": [intent]},
state=state,
config=config,
explicit_model_fallback=keyword_model,
strict_model=False,
disable_thinking=True,
)
# 首轮检索(无任何 pause 干预)且意图解析阶段已顺便生成检索词 → 直接复用,
# 省掉素材收集阶段单独的「生成检索词」LLM 往返。re_search(用户要求重新生成
# 检索词、覆盖式重检)等再入路径 intervention 非空,仍走下方正常生成。
precomputed = state.get("precomputed_keywords") or []
if precomputed and not intervention:
keywords = _clean_keywords(precomputed, intent)
_emit(f"复用意图解析阶段生成的 {len(keywords)} 个检索词…")
else:
kw_result = await llm_json(
EXTRACT_KEYWORDS_SYSTEM,
EXTRACT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
outline_section=outline_section,
),
fallback={"keywords": [intent]},
config=config,
model_name=keyword_model,
disable_thinking=True,
)
# 清洗关键词:剔除占位符 / 空值,全空时回退写作意图
keywords = _clean_keywords(kw_result.get("keywords"), intent)
_emit("正在生成检索词…")
# 用户自带大纲时,把大纲原文带进检索词生成,让关键词结合意图覆盖各章节主题
outline_section = keyword_outline_section(state)
if use_agent:
kw_result = await run_writing_agent(
agent_id=RESEARCHER_AGENT_ID,
user_payload=_AGENT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
outline_section=outline_section,
),
marker=KEYWORDS_READY_MARKER,
fallback={"keywords": [intent]},
state=state,
config=config,
explicit_model_fallback=keyword_model,
strict_model=False,
disable_thinking=True,
)
else:
kw_result = await llm_json(
EXTRACT_KEYWORDS_SYSTEM,
EXTRACT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
outline_section=outline_section,
),
fallback={"keywords": [intent]},
config=config,
model_name=keyword_model,
disable_thinking=True,
)
# 清洗关键词:剔除占位符 / 空值,全空时回退写作意图
keywords = _clean_keywords(kw_result.get("keywords"), intent)
if extra_keywords:
keywords = list(dict.fromkeys(keywords + extra_keywords))
@@ -157,6 +157,11 @@ class AIWritingState(TypedDict):
notebook_search_space_id: Optional[int]
notebook_source_ids: Optional[List[int]]
# 意图解析阶段(intent_parser)顺便生成的检索词:与意图理解共用同一次 LLM 调用,
# researcher 首轮检索时直接复用,省掉素材收集阶段单独的「生成检索词」LLM 往返。
# 解析失败/为空时为 None,researcher 回退到自己生成检索词(优雅降级)。
precomputed_keywords: Optional[List[str]]
# Agent A 输出
material_package: Optional[MaterialPackage]
@@ -514,12 +514,15 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# 关键:绝不给全套工具、绝不复用 lead 超级体提示,否则模型会把「新能源汽车市场分析」之类
# 长得像任务的输入直接做掉,而不是转成写作配置提交。
# 末尾追加 WritingSetupRoundCapMiddleware:澄清最多 3 轮,到顶用 tool_choice 强制提交配置卡。
# 快捷模式:用默认选项、不逐项澄清提问(但仍生成并确认大纲)——直接**不挂** ask_clarification,
# 让模型物理上问不了澄清问题,比只靠提示词约束更可靠。
from deerflow.agents.lead_agent.prompt import build_writing_setup_prompt
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
setup_tools = [setup_writing] if writing_setup_quick else [setup_writing, ask_clarification_tool]
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=[setup_writing, ask_clarification_tool],
tools=setup_tools,
middleware=[
*_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
WritingSetupRoundCapMiddleware(max_rounds=WRITING_SETUP_MAX_ROUNDS),
@@ -1147,8 +1147,8 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None, *, quick
if quick_mode:
workflow_block = """【工作流程——快捷模式】
1. **不澄清、用默认**:**禁止**调用 `ask_clarification`。文章类型、目标字数、目标读者等一律自行取**合理默认**(字数默认 800,读者默认「普通读者」,文章类型按意图判断),**绝不**就这些选项反问用户。
2. **拿到标题/主题就直接出大纲**:用户一给出标题或写作主题,**立刻**以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与默认字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
1. **不澄清、用默认**:**禁止**就文章类型/字数/读者等选项反问用户(你**没有** `ask_clarification` 工具,也**绝不**用普通文本向用户提澄清问题)。这些字段一律自行取**合理默认**(字数默认 800,读者默认「普通读者」,文章类型按意图判断)。
2. **跳过提问、直接出大纲**:用户一给出标题或写作主题,**立刻**以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与默认字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:"""
else:
@@ -1158,18 +1158,20 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None, *, quick
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。用户若只是补充信息也照常更新大纲再确认。
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:"""
quick_law = (
"\n- **快捷模式**:禁止调用 `ask_clarification` 反问选项;缺失项一律取合理默认,用户给出标题/主题后**立刻生成大纲**。"
# 快捷模式与普通模式的唯一区别:是否「逐项澄清提问」。两者都**先生成并确认大纲**再提交。
# 快捷模式不挂 ask_clarification 工具(见 make_lead_agent),故这里据 quick_mode 调整工具说明。
tools_law = (
"- 你只有**一个工具**:`setup_writing`(提交写作配置)——快捷模式**没有** `ask_clarification`,**不向用户提任何澄清问题**,缺失项一律取合理默认。**没有任何其它工具**,不要做检索/研究。"
if quick_mode
else ""
else "- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。"
)
return f"""你是「AI 写作」功能的**配置助手**。你的职责分两段:① 先与用户把写作需求聊清楚,**先生成并与用户确认一份大纲**;② 待用户对大纲满意后,再调用 `setup_writing` 工具提交写作配置(含已确认的大纲),交用户开始写作。
【铁律——最高优先级】
- 你**绝对不撰写**文章正文、分析内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只产出「大纲」(仅结构与标题)与「写作配置」**,绝不替用户动笔、绝不直接完成这篇写作。
- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。
- `setup_writing` **只能在用户明确确认大纲之后**才调用;在那之前绝不调用它。{quick_law}
{tools_law}
- `setup_writing` **只能在用户明确确认大纲之后**才调用;在那之前绝不调用它。
{workflow_block}
- `user_intent`:精炼成一句话的写作目标(必填)。