From 304e1b637a4bea564ce8805c8558523e2fdc542d Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Wed, 24 Jun 2026 18:10:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai=E5=86=99=E4=BD=9C):=20=E6=84=8F?= =?UTF-8?q?=E5=9B=BE=E8=A7=A3=E6=9E=90=E9=A1=BA=E4=BE=BF=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E8=AF=8D=20+=20=E4=BF=AE=E5=A4=8D=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 意图解析(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) --- .../agents/ai_writing/nodes/_utils.py | 20 ++++ .../agents/ai_writing/nodes/intent_parser.py | 30 +++++- .../agents/ai_writing/nodes/researcher.py | 102 ++++++++---------- .../deerflow/agents/ai_writing/state.py | 5 + .../deerflow/agents/lead_agent/agent.py | 5 +- .../deerflow/agents/lead_agent/prompt.py | 16 +-- 6 files changed, 109 insertions(+), 69 deletions(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py index 6c023132..37b4a56e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py @@ -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。 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py index 30f325ed..73c0c773 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py index e84631ca..aaa4458a 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py @@ -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)) 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 71156c0f..89b83a10 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 @@ -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] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 90ab19ec..47920bbb 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -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), diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index b1c1c900..81c4d85b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -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`:精炼成一句话的写作目标(必填)。