feat(ai写作): 自带大纲/会商及研究模板等前后端改动
提交工作区中 AI 写作(表单/时间线/历史/上下文/流式 hook/类型)、 研究模板配置页、lead_agent、researcher/graph/state 及 gateway/config 的进行中改动。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d2c76100f2
commit
e9c8d1cea2
18 files changed
+287
-50
No files matched your search
@@ -239,6 +239,7 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
"is_bootstrap",
|
||||
"is_writing_setup",
|
||||
"valid_article_types",
|
||||
"writing_setup_quick",
|
||||
"excluded_tools",
|
||||
"skill_stop_names",
|
||||
"skill_stop_message",
|
||||
|
||||
@@ -10,18 +10,18 @@ gateway:
|
||||
port: 8001
|
||||
|
||||
models:
|
||||
# - name: deepseek-chat
|
||||
# display_name: DeepSeek Chat
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-chat
|
||||
# api_key: sk-35345154983047aba9e12faa86c5f62c
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-chat
|
||||
api_key: sk-686b7bc000be4b7cbed6dccb3897c411
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
|
||||
- name: zai-org/GLM-5-FP8
|
||||
display_name: zai-org/GLM-5-FP8
|
||||
|
||||
+35
-3
@@ -62,6 +62,17 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
|
||||
approved_ids = set(user_input.get("approvedMaterialIds") or [m["id"] for m in pkg["materials"]])
|
||||
filtered = [m for m in pkg["materials"] if m["id"] in approved_ids]
|
||||
updated_pkg = {**pkg, "materials": filtered}
|
||||
# 用户自带大纲:researcher 已在「搜集素材」阶段解析大纲并归类素材 → 跳过大纲生成步,
|
||||
# 仅把被用户取消勾选的素材从各章节 material_ids 里剔除,直接进入大纲确认。
|
||||
prebuilt = state.get("current_outline")
|
||||
if prebuilt and (state.get("user_outline_raw") or "").strip():
|
||||
return {
|
||||
"material_package": updated_pkg,
|
||||
"current_outline": _prune_outline_material_ids(prebuilt, approved_ids),
|
||||
"last_user_intervention": user_input,
|
||||
"status": "awaiting_outline_confirm",
|
||||
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "已采用你提供的大纲并归类素材,请确认大纲"}],
|
||||
}
|
||||
return {
|
||||
"material_package": updated_pkg,
|
||||
"last_user_intervention": user_input,
|
||||
@@ -90,6 +101,21 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _prune_outline_material_ids(outline: dict, approved_ids: set) -> dict:
|
||||
"""把大纲各章节 material_ids 里、用户在素材确认卡取消勾选的素材编号剔除掉。
|
||||
|
||||
用户自带大纲时素材归类发生在「搜集素材」阶段(researcher),早于素材确认;
|
||||
用户在确认卡里去掉某些素材后,用这个函数把对应编号从大纲剔除,保持一致。
|
||||
"""
|
||||
if not outline:
|
||||
return outline
|
||||
sections = []
|
||||
for s in outline.get("sections", []):
|
||||
kept = [mid for mid in (s.get("material_ids") or []) if mid in approved_ids]
|
||||
sections.append({**s, "material_ids": kept})
|
||||
return {**outline, "sections": sections}
|
||||
|
||||
|
||||
def _normalize_outline(outline: dict) -> dict:
|
||||
"""将前端回传的 camelCase 大纲统一为后端 snake_case 格式。"""
|
||||
if not outline:
|
||||
@@ -318,7 +344,13 @@ def after_researcher(state: AIWritingState) -> str:
|
||||
|
||||
|
||||
def after_material_pause(state: AIWritingState) -> str:
|
||||
return "researcher" if state["status"] == "researching" else "writer_outline"
|
||||
status = state["status"]
|
||||
if status == "researching":
|
||||
return "researcher"
|
||||
# 用户自带大纲:researcher 已在搜集阶段整理好大纲 → 跳过 writer_outline 生成步,直接确认大纲。
|
||||
if status == "awaiting_outline_confirm":
|
||||
return "pause_outline"
|
||||
return "writer_outline"
|
||||
|
||||
|
||||
def after_outline_pause(state: AIWritingState) -> str:
|
||||
@@ -396,9 +428,9 @@ def make_ai_writing_graph(config=None):
|
||||
g.set_entry_point("intent_parser")
|
||||
g.add_edge("intent_parser", "researcher")
|
||||
g.add_conditional_edges("researcher", after_researcher,
|
||||
{"pause_material": "pause_material", "writer_draft": "writer_draft"})
|
||||
{"pause_material": "pause_material", "writer_draft": "writer_draft", "writer_outline": "writer_outline"})
|
||||
g.add_conditional_edges("pause_material", after_material_pause,
|
||||
{"researcher": "researcher", "writer_outline": "writer_outline"})
|
||||
{"researcher": "researcher", "writer_outline": "writer_outline", "pause_outline": "pause_outline"})
|
||||
g.add_edge("writer_outline", "pause_outline")
|
||||
g.add_conditional_edges("pause_outline", after_outline_pause,
|
||||
{"writer_outline": "writer_outline", "writer_draft": "writer_draft"})
|
||||
|
||||
+44
-5
@@ -399,6 +399,22 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
def _emit(message: str) -> None:
|
||||
writer({"type": "research_progress", "message": message})
|
||||
|
||||
# 跳过素材收集:用户在初始表单勾选「不收集素材,直接用通用知识写作」。
|
||||
# 仅在**首次**进入(无任何 pause 干预 = intervention 为空)时短路,避免后续
|
||||
# 「补充检索 / 按输入追加」等再入研究节点时被误跳过。直接产出空素材包、强制
|
||||
# loose 写作模式(无素材可严格依据),并跳过素材确认暂停点直奔大纲规划。
|
||||
if state.get("skip_research") and not intervention:
|
||||
empty_pkg = MaterialPackage(keywords=[], materials=[], summary="用户选择不收集素材,直接用通用知识写作")
|
||||
return {
|
||||
"material_package": empty_pkg,
|
||||
"writing_mode": "loose",
|
||||
"status": "writing_outline",
|
||||
"progress_events": [
|
||||
{"type": "step_start", "agent_name": researcher_label, "message": "已跳过素材收集,直接用通用知识写作"},
|
||||
{"type": "materials_ready", "agent_name": researcher_label, "message": "未收集素材(通用知识写作)"},
|
||||
],
|
||||
}
|
||||
|
||||
# Step 1: 提取关键词
|
||||
kw_result: dict = {} # 走"用户输入 / 追加"分支时跳过 LLM 调用,notice 拼接需要兜底值
|
||||
if is_user_query_search:
|
||||
@@ -856,13 +872,36 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
step_start_msg = f"通用检索(技能编排 {len(skill_steps)} 步):{', '.join(keywords[:3])}"
|
||||
else:
|
||||
step_start_msg = f"搜索关键词:{', '.join(keywords[:3])}"
|
||||
|
||||
progress_events: list[dict] = [
|
||||
{"type": "step_start", "agent_name": researcher_label, "message": step_start_msg},
|
||||
*notices,
|
||||
{"type": "materials_ready", "agent_name": researcher_label, "message": summary},
|
||||
]
|
||||
extra_updates: dict = {}
|
||||
# 用户自带大纲:在「搜集素材」阶段顺便解析大纲并把素材归类到各章节,
|
||||
# 从而跳过 writer_outline 的大纲生成步(见 graph after_material_pause 路由)。
|
||||
# 补充检索(is_supplement)直接回写作、沿用原大纲,不在此整理。
|
||||
if not is_supplement and (state.get("user_outline_raw") or "").strip():
|
||||
from .writer_outline import build_user_outline_with_materials
|
||||
|
||||
try:
|
||||
organized = await build_user_outline_with_materials(state, config, pkg)
|
||||
except Exception:
|
||||
logger.warning("researcher: 整理用户大纲失败(非致命),回退到大纲生成步", exc_info=True)
|
||||
organized = None
|
||||
if organized:
|
||||
extra_updates["current_outline"] = organized
|
||||
progress_events.append({
|
||||
"type": "outline_ready",
|
||||
"agent_name": researcher_label,
|
||||
"message": f"已按你提供的大纲整理素材,共 {len(organized.get('sections', []))} 个章节",
|
||||
})
|
||||
|
||||
return {
|
||||
"material_package": pkg,
|
||||
# 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点
|
||||
"status": "writing_draft" if is_supplement else "awaiting_material_confirm",
|
||||
"progress_events": [
|
||||
{"type": "step_start", "agent_name": researcher_label, "message": step_start_msg},
|
||||
*notices,
|
||||
{"type": "materials_ready", "agent_name": researcher_label, "message": summary},
|
||||
],
|
||||
**extra_updates,
|
||||
"progress_events": progress_events,
|
||||
}
|
||||
@@ -144,6 +144,10 @@ class AIWritingState(TypedDict):
|
||||
# notebook=我的空间(仅笔记检索)。旧值 knowledge_base / skill 由 researcher
|
||||
# 节点按 general 兼容处理。
|
||||
material_source: str
|
||||
# 跳过素材收集:用户在初始表单勾选「不收集素材,直接用通用知识写作」时为 True。
|
||||
# researcher 节点在**首次**进入(无任何 pause 干预)时短路,跳过整个检索流程直接
|
||||
# 去大纲规划;强制 loose 写作模式(没有素材可严格依据)。
|
||||
skip_research: bool
|
||||
# 用户在写作表单选定的主模型。大纲/写作/审核严格使用它(不回退默认)。
|
||||
# 由 intent_parser 在首次 invocation 从 configurable.model_name 捕获进 state,
|
||||
# 经 checkpoint 持久化 —— interrupt/resume 是新 invocation、其 config 不带原
|
||||
|
||||
+3
-1
@@ -437,6 +437,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
# 走写作配置专用 prompt,只负责帮用户把写作表单聊清并提交,不真正开始写作。
|
||||
is_writing_setup = cfg.get("is_writing_setup", False)
|
||||
writing_setup_article_types = cfg.get("valid_article_types") if is_writing_setup else None
|
||||
# 快捷模式:跳过逐项选项澄清,给标题直接出大纲(见 build_writing_setup_prompt)。
|
||||
writing_setup_quick = bool(cfg.get("writing_setup_quick", False)) if is_writing_setup else False
|
||||
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
|
||||
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
|
||||
writing_mode = bool(cfg.get("writing_mode", False))
|
||||
@@ -522,7 +524,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
*_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
|
||||
WritingSetupRoundCapMiddleware(max_rounds=WRITING_SETUP_MAX_ROUNDS),
|
||||
],
|
||||
system_prompt=build_writing_setup_prompt(writing_setup_article_types),
|
||||
system_prompt=build_writing_setup_prompt(writing_setup_article_types, quick_mode=writing_setup_quick),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
|
||||
+26
-7
@@ -1116,13 +1116,17 @@ def _build_bootstrap_section() -> str:
|
||||
"""
|
||||
|
||||
|
||||
def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
|
||||
def build_writing_setup_prompt(article_types: list[dict] | None = None, *, quick_mode: bool = False) -> str:
|
||||
"""写作配置 Q&A 的**独立**系统提示(不复用 lead-agent 超级体提示)。
|
||||
|
||||
关键:写作配置助手绝不能像 lead agent 那样「自己把任务做掉」——用户输入常常长得像一个
|
||||
可执行任务(如「新能源汽车市场分析」),若复用 lead 提示 + 全套工具,模型会直接去研究/写
|
||||
分析。这里用一份职责单一、口吻强硬的独立提示,配合「只挂 setup_writing 一个工具」,把模型
|
||||
死死框在「聊清需求 → 调 setup_writing」上。
|
||||
|
||||
``quick_mode``(快捷模式):跳过「逐项选项澄清」——文章类型/字数/读者等一律取合理默认,
|
||||
**不调用 ``ask_clarification``**,用户一给出标题/主题就**直接生成大纲**;大纲仍交用户确认,
|
||||
确认后再调 ``setup_writing`` 提交。供前端「快捷模式」开关使用。
|
||||
"""
|
||||
if article_types:
|
||||
type_lines = "\n".join(
|
||||
@@ -1141,18 +1145,33 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
|
||||
"`article_type_detail` 里用一句话说明其写作侧重;不确定可留空,交给用户在表单里选。"
|
||||
)
|
||||
|
||||
if quick_mode:
|
||||
workflow_block = """【工作流程——快捷模式】
|
||||
1. **不澄清、用默认**:**禁止**调用 `ask_clarification`。文章类型、目标字数、目标读者等一律自行取**合理默认**(字数默认 800,读者默认「普通读者」,文章类型按意图判断),**绝不**就这些选项反问用户。
|
||||
2. **拿到标题/主题就直接出大纲**:用户一给出标题或写作主题,**立刻**以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与默认字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
|
||||
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。
|
||||
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:"""
|
||||
else:
|
||||
workflow_block = """【工作流程】
|
||||
1. **聊清需求**:读用户输入,当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 提问——**优先给出 `options` 选项**让用户点选(如目标字数「800 / 1500 / 3000」、读者「普通读者 / 行业专家 / 政策决策者」)。澄清**最多 2 次**,信息够了就进入下一步,不要无休止追问。
|
||||
2. **生成大纲(先于确认意图)**:信息足够后,直接以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与目标字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
|
||||
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。用户若只是补充信息也照常更新大纲再确认。
|
||||
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:"""
|
||||
|
||||
quick_law = (
|
||||
"\n- **快捷模式**:禁止调用 `ask_clarification` 反问选项;缺失项一律取合理默认,用户给出标题/主题后**立刻生成大纲**。"
|
||||
if quick_mode
|
||||
else ""
|
||||
)
|
||||
|
||||
return f"""你是「AI 写作」功能的**配置助手**。你的职责分两段:① 先与用户把写作需求聊清楚,**先生成并与用户确认一份大纲**;② 待用户对大纲满意后,再调用 `setup_writing` 工具提交写作配置(含已确认的大纲),交用户开始写作。
|
||||
|
||||
【铁律——最高优先级】
|
||||
- 你**绝对不撰写**文章正文、分析内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只产出「大纲」(仅结构与标题)与「写作配置」**,绝不替用户动笔、绝不直接完成这篇写作。
|
||||
- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。
|
||||
- `setup_writing` **只能在用户明确确认大纲之后**才调用;在那之前绝不调用它。
|
||||
- `setup_writing` **只能在用户明确确认大纲之后**才调用;在那之前绝不调用它。{quick_law}
|
||||
|
||||
【工作流程】
|
||||
1. **聊清需求**:读用户输入,当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 提问——**优先给出 `options` 选项**让用户点选(如目标字数「800 / 1500 / 3000」、读者「普通读者 / 行业专家 / 政策决策者」)。澄清**最多 2 次**,信息够了就进入下一步,不要无休止追问。
|
||||
2. **生成大纲(先于确认意图)**:信息足够后,直接以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与目标字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
|
||||
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。用户若只是补充信息也照常更新大纲再确认。
|
||||
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:
|
||||
{workflow_block}
|
||||
- `user_intent`:精炼成一句话的写作目标(必填)。
|
||||
- `article_type`:见下方【文章类型】——优先选已有类型的 key;都不贴切才自拟一个新的中文类型名。
|
||||
- `article_type_detail`:**仅当 article_type 是新类型时**,用一句话说明该类型的写作侧重/特征;命中已有类型则留空。
|
||||
|
||||
Reference in new issue
Block a user