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:
1 parent
49173d9c28
commit
304e1b637a
6 files changed
+109
-69
No files matched your search
+20
@@ -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。
|
||||
|
||||
|
||||
+25
-5
@@ -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
|
||||
+46
-56
@@ -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]
|
||||
|
||||
|
||||
+4
-1
@@ -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),
|
||||
|
||||
+9
-7
@@ -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`:精炼成一句话的写作目标(必填)。
|
||||
|
||||
Reference in new issue
Block a user