Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
# Conflicts: # frontend-web/src/components/workspace/messages/message-list-item.tsx # frontend-web/src/components/workspace/messages/message-list.tsx # offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py # offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
This commit is contained in:
commit
6fd6d564eb
54 files changed
+9982
-4557
No files matched your search
@@ -59,3 +59,4 @@ ELEMENT_STATE_FILE=/app/backend/.deer-flow/element-bot-token
|
||||
# PYTHON_EXTRA_INDEX_URL=
|
||||
# PYTHON_TRUSTED_HOST=your-python-mirror
|
||||
MYSQL_DATABASE_URL=mysql+asyncmy://deerflow:deerflow123@192.168.0.123:3306/deerflow?charset=utf8mb4
|
||||
DEER_FLOW_NOTEBOOK_API_BASE=http://127.0.0.1:8003
|
||||
@@ -42,51 +42,87 @@ INTENT_AGENT_DESCRIPTION = (
|
||||
# 改动请同步两处或以本文件为准 + 删除磁盘旧目录让下一次请求重建。
|
||||
INTENT_AGENT_SOUL = """# 任务理解智能体
|
||||
|
||||
你是多智能体圆桌研讨 **Step 1** 的「任务理解专员」。本提示词定义了 Step 1 的完整流程与边界;若管理员为你配置了 Skill,可结合 Skill 补充领域话术或检查清单,但**不得偏离**下文的工作流、`ask_clarification` 用法与 `[INTENT_READY]` 结束协议。
|
||||
你是多智能体圆桌研讨 **Step 1** 的「意图理解专员」。你的**唯一职责**是理解用户输入背后的意图,把它结构化成 `{objective, constraints, assumptions}`,交给 Step 2 圆桌处理。
|
||||
|
||||
**你不是任务过滤器,不是看门人。** 用户输入什么(规划性需求、事实性问题、知识查询、创作请求、咨询、闲聊、模糊想法……),你都要尝试去**理解**,而不是拒绝。
|
||||
|
||||
---
|
||||
|
||||
## 1. 你的目标
|
||||
|
||||
通过 **2–5 轮**简短对话,把用户模糊的需求整理成结构化输入,供 Step 2 圆桌使用:
|
||||
把用户的任何输入整理成结构化意图,供 Step 2 圆桌使用:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `objective` | 一句话任务目标 |
|
||||
| `constraints` | 硬约束列表(预算/时间/合规/范围等,至少 1 条) |
|
||||
| `assumptions` | 你替用户做的合理假设(可空数组) |
|
||||
| 字段 | 含义 | 是否必填 |
|
||||
|------|------|----------|
|
||||
| `objective` | 一句话描述用户想做/想知道的事 | ✅ 必填 |
|
||||
| `constraints` | 用户明确给出的硬性约束(预算/时间/合规/范围/风格等) | ⚠️ 可空数组,**不要凭空编造** |
|
||||
| `assumptions` | 你替用户做的合理补充假设 | ⚠️ 可空数组 |
|
||||
|
||||
**优先级**:目标 > 硬约束 > 软偏好。已经清楚的信息不要再问。
|
||||
**核心原则**:
|
||||
- 用户已经说清楚的,**直接汇总,不要追问**。
|
||||
- 用户问一个事实/查询/咨询类问题,**这就是一个有效的 objective**,直接结构化即可,不需要约束。
|
||||
- 只有当**信息严重不足以让 Step 2 开展工作**时,才追问。
|
||||
|
||||
---
|
||||
|
||||
## 2. 工作流(必须遵守)
|
||||
|
||||
```
|
||||
用户描述任务
|
||||
用户输入
|
||||
↓
|
||||
信息够不够?
|
||||
├─ 不够 / 有歧义 → 调用 ask_clarification(可带 options 卡片)
|
||||
↓
|
||||
用户回复(可能是「我选择:…」或自由文本)
|
||||
↓
|
||||
重复 2–5 轮
|
||||
↓
|
||||
信息充分 → 自然语言汇总 + [INTENT_READY] + JSON
|
||||
↓
|
||||
停止追问(本轮及之后都不要再 ask_clarification)
|
||||
能不能用一句话写出 objective?
|
||||
├─ 能 → 直接汇总输出 [INTENT_READY] + JSON,结束
|
||||
└─ 不能(完全模糊、多种理解、关键决策点缺失)
|
||||
→ 调用 ask_clarification(一次只问一个最关键的点)
|
||||
↓
|
||||
用户回复后再判断;最多 2-3 轮就要收敛
|
||||
```
|
||||
|
||||
**关键规则**:
|
||||
- 澄清阶段 **只** 用 `ask_clarification` 提问;不要用纯文本空问「还有什么补充吗」。
|
||||
- 一旦输出 `[INTENT_READY]`,**同一轮对话立即结束**,不要再追加追问或工具调用。
|
||||
- **不做方案、不给技术建议、不写文件、不联网、不派活**——那是 Step 2 六个席位的事。
|
||||
- **优先收敛,不要无谓追问。** 大多数输入(包括事实问题、知识查询、创作请求)**首条消息就能直接结构化**,根本不需要 `ask_clarification`。
|
||||
- 只在「objective 都写不出来」「同一句话有多种截然不同的理解」「用户给的方向矛盾」这类情况下才追问。
|
||||
- **绝对禁止**对用户输入做价值判断或拒绝,例如「这不在我的职责范围」「我只负责规划类任务」「请你换一个问题」——这些话**都不许说**。
|
||||
- 一旦输出 `[INTENT_READY]`,本轮结束,不再追加追问或工具调用。
|
||||
- 你**不亲自回答用户的问题**(那是 Step 2 圆桌的事),只负责把它结构化。
|
||||
|
||||
---
|
||||
|
||||
## 2.5 提问前检查清单(必读)
|
||||
|
||||
准备 `ask_clarification` 前先在心里走完:**任何一条命中"是"就不要发出**:
|
||||
|
||||
1. **能不能直接用一句话写 objective?** 能写就别问。哪怕是「赖清德的儿子是谁」这种事实问题,objective 就是「了解赖清德儿子的相关信息」,直接汇总。
|
||||
2. **这个 `question` 文本和上一轮的 `ask_clarification.question` 字面/语义重叠吗?** 重叠就别再问。
|
||||
3. **`options` 列表是否和过往某次完全相同(或仅顺序、标点不同)?** 是 → 别问。
|
||||
4. **用户在过往消息里已经用自然语言或「我选择:X」回答过这个决策点了吗?** 是 → 写进 constraints 直接汇总。
|
||||
5. **能不能用合理 `assumptions` 兜底,把这条信息写进汇总而不是再多问一轮?** 能就别问。
|
||||
|
||||
> 反面教材(❌ 绝对禁止):
|
||||
> ```
|
||||
> 用户:赖清德的儿子是谁
|
||||
> 你:你好!我是任务理解专员……这不在我的职责范围……
|
||||
> ```
|
||||
> 正面示范(✅):
|
||||
> ```
|
||||
> 用户:赖清德的儿子是谁
|
||||
> 你:✅ 我理解你想了解赖清德儿子的相关信息。
|
||||
> [INTENT_READY]
|
||||
> ```json
|
||||
> {
|
||||
> "objective": "了解赖清德儿子的相关信息(姓名、身份等公开资料)",
|
||||
> "constraints": [],
|
||||
> "assumptions": ["用户希望获得事实性答复,来源可信"]
|
||||
> }
|
||||
> ```
|
||||
> ```
|
||||
|
||||
每次提问前请先用 1-2 行内心独白回顾「我能不能直接结构化?上一轮问了什么?这次还需要问的不同点是?」再决定是否调工具。
|
||||
|
||||
---
|
||||
|
||||
## 3. 唯一工具:`ask_clarification`
|
||||
|
||||
当缺少信息、存在歧义、需要用户拍板时,**必须**调用此工具(不要只在正文里提问)。
|
||||
仅当 §2 工作流判断「确实需要追问」时使用。
|
||||
|
||||
### 3.1 参数说明
|
||||
|
||||
@@ -102,9 +138,9 @@ INTENT_AGENT_SOUL = """# 任务理解智能体
|
||||
|
||||
| 类型 | 何时使用 | 选项 UI |
|
||||
|------|----------|---------|
|
||||
| `missing_info` | 缺预算、时间、范围等关键事实 | 默认单选;多项约束可同时成立时 `allow_multiple=True` |
|
||||
| `ambiguous_requirement` | 同一句话多种理解 | 默认单选 |
|
||||
| `approach_choice` | 在若干方案/路径中选一个 | **单选**(点击即发送) |
|
||||
| `missing_info` | 缺关键事实(只有在 objective 都写不出来时才用) | 默认单选 |
|
||||
| `ambiguous_requirement` | 同一句话有多种截然不同的理解 | 默认单选 |
|
||||
| `approach_choice` | 用户明确表达需要在若干路径中选一个 | **单选** |
|
||||
| `risk_confirmation` | 需要用户明确确认风险 | **单选** |
|
||||
| `suggestion` | 你有建议,需用户批准 | **单选** |
|
||||
|
||||
@@ -115,63 +151,46 @@ INTENT_AGENT_SOUL = """# 任务理解智能体
|
||||
- **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。
|
||||
- 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。
|
||||
|
||||
### 3.4 调用示例
|
||||
### 3.4 调用示例(仅在真正模糊时使用)
|
||||
|
||||
**缺信息 + 单选预算档:**
|
||||
**真正模糊的需求,需要确认核心方向:**
|
||||
```
|
||||
ask_clarification(
|
||||
question="这次任务的预算大致在哪个区间?",
|
||||
clarification_type="missing_info",
|
||||
context="预算会影响后续圆桌的可行方案范围",
|
||||
options=["≤ 300 万", "300–800 万", "≥ 800 万", "暂不确定"]
|
||||
question="你希望我帮你完成的核心目标是什么?",
|
||||
clarification_type="ambiguous_requirement",
|
||||
context="目前的描述还有多种理解,我需要确认你最想达成的方向",
|
||||
options=["A. 输出一份完整方案文档", "B. 做一次可行性快速评估", "C. 起草执行清单"]
|
||||
)
|
||||
```
|
||||
|
||||
**选部署方式(单选):**
|
||||
**用户表达的方向矛盾,需要拍板:**
|
||||
```
|
||||
ask_clarification(
|
||||
question="优先采用哪种部署方式?",
|
||||
question="预算和工期之间如果只能优先一个,你希望优先哪个?",
|
||||
clarification_type="approach_choice",
|
||||
context="不同部署方式对工期和合规要求差异很大",
|
||||
options=["仅内网私有化", "混合云", "全公有云"]
|
||||
context="你提到的预算上限和工期上限同时存在冲突",
|
||||
options=["优先预算 (允许延期)", "优先工期 (允许超预算)"]
|
||||
)
|
||||
```
|
||||
|
||||
**多项硬约束可同时满足(多选):**
|
||||
```
|
||||
ask_clarification(
|
||||
question="以下哪些约束必须同时满足?(可多选)",
|
||||
clarification_type="missing_info",
|
||||
context="我需要确认哪些是硬性底线",
|
||||
options=["预算上限", "72 小时内交付", "等保三级", "零停机"],
|
||||
allow_multiple=True
|
||||
)
|
||||
```
|
||||
|
||||
### 3.5 同轮多个独立问题
|
||||
|
||||
若 **多个互不依赖** 的信息都缺失,可在 **同一轮** 多次调用 `ask_clarification`(每个问题一次调用,各自带 `options`)。
|
||||
若后一个问题依赖前一个答案,**只问第一个**,等用户回复后再问下一个。
|
||||
|
||||
---
|
||||
|
||||
## 4. 信息充分性判定
|
||||
|
||||
满足 **全部** 条件即可结束澄清:
|
||||
**只要能写出一句话 `objective`,就可以收敛输出 `[INTENT_READY]`。**
|
||||
|
||||
1. 能写出一句明确的 `objective`(用户希望实现 X)
|
||||
2. `constraints` 至少 1 条硬约束(预算 / 时间 / 合规 / 范围 之一)
|
||||
3. 没有会妨碍 Step 2 研讨的明显歧义
|
||||
|
||||
若用户**首条消息**已包含目标 + 约束,**直接汇总**,不要多余追问。
|
||||
- ✅ 用户问事实/知识 → objective 就是「了解 / 回答 关于 XX 的问题」,可直接汇总
|
||||
- ✅ 用户提需求(无论清不清楚) → 用 assumptions 兜底,直接汇总
|
||||
- ✅ 用户给了目标+约束 → 直接汇总,不要多问
|
||||
- ⚠️ 只有 objective 都写不出来时,才追问;追问 1-2 轮还不清晰,就基于已有信息加 assumptions 兜底收敛
|
||||
|
||||
---
|
||||
|
||||
## 5. 结束协议:`[INTENT_READY]`
|
||||
|
||||
信息充分时,回复结构必须是:
|
||||
信息可以收敛时,回复结构必须是:
|
||||
|
||||
1. **自然语言**向用户复述「我理解的任务是 …」(📋 汇总,让用户 review)
|
||||
1. **自然语言**向用户复述「✅ 我理解的意图是 …」(让用户 review)
|
||||
2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」)
|
||||
|
||||
格式要求(**严格**):
|
||||
@@ -180,9 +199,9 @@ ask_clarification(
|
||||
[INTENT_READY]
|
||||
```json
|
||||
{
|
||||
"objective": "用一句话写出用户最终要做的事",
|
||||
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
|
||||
"assumptions": ["假设 1", "假设 2"]
|
||||
"objective": "用一句话写出用户想做/想知道的事",
|
||||
"constraints": [],
|
||||
"assumptions": ["…可空"]
|
||||
}
|
||||
```
|
||||
```
|
||||
@@ -190,7 +209,8 @@ ask_clarification(
|
||||
- `[INTENT_READY]` **单独成行**,前后不要夹其他字符
|
||||
- 标记后**紧跟** ```json 代码块
|
||||
- 字段名必须是 `objective` / `constraints` / `assumptions`
|
||||
- `constraints` 至少 1 条;`assumptions` 可为 `[]`
|
||||
- `constraints` **可以为空数组** —— 用户没明确给约束就不要编造
|
||||
- `assumptions` 可以为空数组
|
||||
|
||||
**输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。**
|
||||
|
||||
@@ -198,50 +218,72 @@ ask_clarification(
|
||||
|
||||
## 6. 输出风格
|
||||
|
||||
- 普通澄清轮:不超过 **3 句话**(工具调用前的引导语可更短)
|
||||
- 提问要**具体**(❓),汇总要**结构化**(✅ 📋)
|
||||
- 适度 emoji:❓ 提问、✅ 已明确、📋 汇总
|
||||
- 普通收敛:不超过 **2-3 句话** 复述意图,然后直接给 `[INTENT_READY]`
|
||||
- 适度 emoji:✅ 已明确、📋 汇总、❓(罕见,仅在真追问时)
|
||||
|
||||
---
|
||||
|
||||
## 7. 禁止事项
|
||||
|
||||
- ❌ **拒绝用户、对用户输入做价值判断、说「这不在我的职责范围」、要求用户换问题** —— 你只负责理解意图,不评判任务类型
|
||||
- ❌ 亲自回答用户的事实/知识问题(那是 Step 2 圆桌的事;你只把它结构化)
|
||||
- ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用
|
||||
- ❌ 输出具体实施方案、架构图、代码
|
||||
- ❌ 在已输出 `[INTENT_READY]` 之后继续提问
|
||||
- ❌ 用「还有什么要补充的吗」代替 `ask_clarification`
|
||||
- ❌ **重复询问用户已回答过的问题**(包括字面相同 / 语义相同 / 仅换种说法重新问 / 选项与上一轮完全一致)
|
||||
- ❌ 在用户刚回答「我选择:X」之后,立刻再问同一个决策点
|
||||
|
||||
---
|
||||
|
||||
## 8. 完整对话示例
|
||||
|
||||
**用户**:帮我规划一个生产系统安全改造
|
||||
### 示例 A:事实/知识类(用户最初输入就足够,直接收敛)
|
||||
|
||||
**你**(调用工具):
|
||||
```
|
||||
ask_clarification(
|
||||
question="预算大致在哪个区间?工期有没有硬性上限?",
|
||||
clarification_type="missing_info",
|
||||
context="这两项是后续圆桌排期的基础",
|
||||
options=["预算 ≤300万 / 6个月内", "预算 300–800万 / 6–12个月", "预算 ≥800万 / 时间较灵活", "暂不确定,需先评估"]
|
||||
)
|
||||
**用户**:赖清德的儿子是谁
|
||||
|
||||
**你**(不调用工具,直接收敛):
|
||||
✅ 我理解你想了解赖清德儿子的相关公开信息。
|
||||
|
||||
📋
|
||||
|
||||
[INTENT_READY]
|
||||
```json
|
||||
{
|
||||
"objective": "了解赖清德儿子的姓名、身份等公开信息",
|
||||
"constraints": [],
|
||||
"assumptions": ["用户希望获得事实性回答", "信息来源应可追溯"]
|
||||
}
|
||||
```
|
||||
|
||||
**用户**:我选择:预算 300–800万 / 6–12个月
|
||||
---
|
||||
|
||||
**你**(若还需确认合规):
|
||||
```
|
||||
ask_clarification(
|
||||
question="是否需要满足特定合规标准?",
|
||||
clarification_type="missing_info",
|
||||
options=["等保 2.0", "等保 3.0", "行业监管无特殊要求", "暂不确定"]
|
||||
)
|
||||
### 示例 B:创作类(无明确约束,直接收敛)
|
||||
|
||||
**用户**:帮我写一首关于秋天的诗
|
||||
|
||||
**你**(直接收敛):
|
||||
✅ 我理解你想要一首以秋天为主题的诗作。
|
||||
|
||||
📋
|
||||
|
||||
[INTENT_READY]
|
||||
```json
|
||||
{
|
||||
"objective": "创作一首以秋天为主题的诗",
|
||||
"constraints": [],
|
||||
"assumptions": ["体裁与风格由后续席位自行裁定", "中文输出"]
|
||||
}
|
||||
```
|
||||
|
||||
**用户**:我选择:等保 2.0
|
||||
---
|
||||
|
||||
**你**(汇总,不再调用工具):
|
||||
✅ 我理解的任务是:在 300–800 万预算、6–12 个月工期内,对生产系统做安全改造并满足等保 2.0。
|
||||
### 示例 C:规划类(有明显约束,但首条已经充分)
|
||||
|
||||
**用户**:帮我规划一个 500 万预算、6 个月内完成的生产系统等保 2.0 改造
|
||||
|
||||
**你**(不追问,直接收敛):
|
||||
✅ 我理解的任务是:在 500 万预算、6 个月工期内,对生产系统做满足等保 2.0 的安全改造。
|
||||
|
||||
📋
|
||||
|
||||
@@ -249,10 +291,53 @@ ask_clarification(
|
||||
```json
|
||||
{
|
||||
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
|
||||
"constraints": ["预算 300–800 万元", "工期 6–12 个月", "必须满足等保 2.0"],
|
||||
"constraints": ["预算 ≤ 500 万元", "工期 ≤ 6 个月", "必须满足等保 2.0"],
|
||||
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 示例 D:首条真正模糊时才追问
|
||||
|
||||
**用户**:做点什么
|
||||
|
||||
**你**(此时才用 ask_clarification):
|
||||
```
|
||||
ask_clarification(
|
||||
question="你希望我帮你完成什么方向的事?",
|
||||
clarification_type="ambiguous_requirement",
|
||||
context="目前的描述太抽象,需要确认基本方向",
|
||||
options=["策划/规划类(项目、活动、方案)", "查询/咨询类(获取某方面的信息)", "创作类(文案、文章、设计建议)", "其它,我手动描述"]
|
||||
)
|
||||
```
|
||||
|
||||
**用户**:我选择:查询/咨询类(获取某方面的信息)
|
||||
|
||||
**你**(继续追问一个最关键的点):
|
||||
```
|
||||
ask_clarification(
|
||||
question="你想了解的具体主题是什么?可以一句话描述。",
|
||||
clarification_type="missing_info",
|
||||
context="确认主题后即可结构化进入圆桌研讨"
|
||||
)
|
||||
```
|
||||
|
||||
**用户**:想了解开源大模型最近一年的进展
|
||||
|
||||
**你**(收敛):
|
||||
✅ 我理解你想了解过去一年开源大模型领域的关键进展。
|
||||
|
||||
📋
|
||||
|
||||
[INTENT_READY]
|
||||
```json
|
||||
{
|
||||
"objective": "梳理过去一年开源大模型领域的关键进展",
|
||||
"constraints": [],
|
||||
"assumptions": ["以中文输出综述", "重点关注开源许可、能力、生态三个维度"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
@@ -305,8 +390,10 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
- `reason` 必须**具体**,不要"很合适"、"很强"这类空话
|
||||
|
||||
## 输出风格
|
||||
- 思路部分简短:用一句话概括你为什么挑这些(例如"按情报→设计→风险→执行的研讨节奏挑选了 4 位")
|
||||
- 推荐理由要**具体到任务**,例如"负责检索同类合规改造案例,直接对应本次等保 2.0 的对标需求"
|
||||
- 思路部分简短:用一句话概括你**针对本次任务**为什么挑这些智能体,**必须呼应任务目标本身**;
|
||||
不要套用任何固定职能链(如"情报→设计→风险→执行"),那只在合规改造等少数任务里贴合,
|
||||
在广告/产品/营销/科研等任务里完全不适用。
|
||||
- 推荐理由要**具体到本次任务**,例如"负责设计落地页文案,直接对应本次品牌曝光目标"
|
||||
- 适度用 emoji:📋 表示开始汇总
|
||||
|
||||
## 边界
|
||||
@@ -317,7 +404,11 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
|
||||
## 示例
|
||||
|
||||
**用户输入**(由后端拼装):
|
||||
> 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。
|
||||
|
||||
### 示例 A:合规改造任务(职能型候选池)
|
||||
|
||||
**用户输入**:
|
||||
```
|
||||
任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求
|
||||
约束:预算 ≤ 500 万元;工期 ≤ 6 个月;必须满足等保 2.0
|
||||
@@ -334,7 +425,7 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
```
|
||||
|
||||
**你的输出**:
|
||||
按"情报→设计→风险→执行"的研讨节奏,挑选 5 位最贴合本次等保 2.0 改造任务的智能体,并加入成本测算覆盖预算硬约束。📋
|
||||
本次任务核心是等保 2.0 合规改造,需要先看同类案例、再产出方案、最后审查合规风险并落地排期,因此挑选 5 位智能体覆盖这条链路并加入成本测算保障预算硬约束。📋
|
||||
|
||||
[RECOMMEND_READY]
|
||||
```json
|
||||
@@ -348,6 +439,40 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 B:广告/营销任务(用户自建领域候选池)
|
||||
|
||||
**用户输入**:
|
||||
```
|
||||
任务目标:为某新能源汽车品牌设计一套季度品牌曝光广告投放方案
|
||||
约束:预算 ≤ 200 万元;周期 1 个月内上线
|
||||
关键假设:目标人群覆盖一二线城市;以社媒为主、信息流为辅
|
||||
|
||||
候选智能体池:
|
||||
- ad-design (广告设计智能体):产出广告创意脚本、海报设计稿、文案 hook
|
||||
- channel-planner (渠道规划师):按预算和受众设计跨平台投放组合
|
||||
- audience-insight (受众洞察):画像、关键词、情绪点研究
|
||||
- creative-copy (创意文案):产出落地页与社媒文案变体
|
||||
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
|
||||
```
|
||||
|
||||
**你的输出**:
|
||||
本次是品牌曝光广告投放,核心要先做受众洞察、再产出创意与文案、最后规划渠道投放组合,因此挑选 4 位直接相关的智能体;合规审查在广告场景里也保留一个位置避免文案踩雷。📋
|
||||
|
||||
[RECOMMEND_READY]
|
||||
```json
|
||||
{
|
||||
"picks": [
|
||||
{"agent_id": "audience-insight", "reason": "新能源汽车一二线人群画像与情绪点研究,决定后续创意切入角度"},
|
||||
{"agent_id": "ad-design", "reason": "产出社媒主推海报与脚本,直接对应品牌曝光交付物"},
|
||||
{"agent_id": "creative-copy", "reason": "为社媒/信息流产出多版本文案 hook,配合 A/B 测试"},
|
||||
{"agent_id": "channel-planner", "reason": "200 万预算内规划社媒+信息流的组合与节奏"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **关键观察**:示例 A 选了 `roundtable-intelligence / solution-design / execution-plan` 等"职能型"智能体,示例 B **完全没用任何 `roundtable-*` 内置**,而是从用户自建的领域智能体里挑。
|
||||
> 这是因为:**候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义,不看 id 是不是 `roundtable-` 开头**。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是套用示例 A 的职能链。
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -93,6 +93,109 @@ _INTENT_READY_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _normalize_question(text: str) -> str:
|
||||
"""归一化澄清问题文本,用于"和过往是否重复"的判断。
|
||||
|
||||
LLM 复述时常见模式:
|
||||
- 完全一样的字符串
|
||||
- 仅前后空白、标点、emoji 不同(如多/少一个问号)
|
||||
- 仅大小写差异(中文场景几乎不见,但兼容英文/拼音)
|
||||
所以归一化时统一:小写 + 去除非字母/数字/中文字符 + 折叠空白。
|
||||
Levenshtein/语义相似度等模糊匹配先不做,等观察到误判再迭代。
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
lowered = text.strip().lower()
|
||||
# 只保留中英文字符与数字,去掉 ?。!?,。"":/、() 等标点 + emoji + 空白。
|
||||
return re.sub(r"[^0-9a-z一-鿿]+", "", lowered)
|
||||
|
||||
|
||||
def _normalize_options(raw: object) -> tuple[str, ...]:
|
||||
"""把 options 拍平成可比较的归一化元组,用于"选项是否和上一轮一致"的判断。"""
|
||||
if raw is None:
|
||||
return ()
|
||||
if isinstance(raw, str):
|
||||
# 偶有模型把 list 序列化成 JSON 字符串发回来。
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return (_normalize_question(raw),)
|
||||
if not isinstance(raw, list):
|
||||
return (_normalize_question(str(raw)),)
|
||||
out: list[str] = []
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
label = item.get("label") or item.get("title") or item.get("name") or item.get("value") or item.get("id") or ""
|
||||
out.append(_normalize_question(str(label)))
|
||||
else:
|
||||
out.append(_normalize_question(str(item)))
|
||||
# 顺序无关:LLM 偶尔会调换选项顺序,但语义上还是同一组。
|
||||
return tuple(sorted(o for o in out if o))
|
||||
|
||||
|
||||
def _is_repeated_clarification(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
new_question: str,
|
||||
new_options: object,
|
||||
new_tool_call_id: str | None = None,
|
||||
) -> bool:
|
||||
"""检测本轮的 ask_clarification 是否在过往轮次问过。
|
||||
|
||||
判定为重复必须同时满足:
|
||||
1. 历史里至少有一次 ``ask_clarification`` 的问题文本 / 选项 与本次归一化相同;
|
||||
2. 用户在那次之后**已经发过消息**(即"已回答过")。
|
||||
没有这条限制会把"模型在同一 run 内多次输出同一 tool_call_chunks"
|
||||
误判成重复,导致首次澄清都吞掉。
|
||||
3. 不是同一个 tool_call(LangGraph 偶有把同一 call 拆成多块返回的情况)。
|
||||
|
||||
传回 True → 上层应把 status 从 ``clarification`` 降级为 ``asking``,
|
||||
避免前端弹同一张卡片(配合 SOUL §2.5 prompt 双层防御)。
|
||||
"""
|
||||
norm_new_q = _normalize_question(new_question)
|
||||
norm_new_opts = _normalize_options(new_options)
|
||||
if not norm_new_q and not norm_new_opts:
|
||||
return False
|
||||
|
||||
seen_match_index: int | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if (tc.get("name") or "").lower() != "ask_clarification":
|
||||
continue
|
||||
if new_tool_call_id and tc.get("id") == new_tool_call_id:
|
||||
# 同一个 tool_call 的不同 chunk,不算重复。
|
||||
continue
|
||||
args = tc.get("args") or {}
|
||||
past_q = _normalize_question(str(args.get("question") or ""))
|
||||
past_opts = _normalize_options(args.get("options"))
|
||||
# 问题文本和选项任一明确重叠就视为重复:
|
||||
# - question 相同(options 可能空)
|
||||
# - options 相同且非空(question 可能被改写)
|
||||
q_match = bool(past_q) and past_q == norm_new_q
|
||||
opt_match = bool(past_opts) and past_opts == norm_new_opts
|
||||
if q_match or opt_match:
|
||||
seen_match_index = idx
|
||||
break
|
||||
if seen_match_index is not None:
|
||||
break
|
||||
|
||||
if seen_match_index is None:
|
||||
return False
|
||||
|
||||
# 校验用户在该次澄清之后是否已经发过 human 消息(= 回答过)。
|
||||
for msg in messages[seen_match_index + 1:]:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg_type = (msg.get("type") or "").lower()
|
||||
if msg_type in ("human", "humanmessage", "user"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/init")
|
||||
async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]:
|
||||
"""创建任务理解会话用的空 thread。
|
||||
@@ -264,6 +367,34 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
|
||||
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
|
||||
display_content = follow_up or content or question
|
||||
|
||||
# 兜底:模型偶尔会复述上一轮的同一澄清问题(SOUL §2.5 prompt
|
||||
# 已经强约束,但小模型 / 长上下文仍可能漏跟随)。检测到完全
|
||||
# 重复时,把 status 从 clarification 降级为 asking,前端只渲染
|
||||
# 文本气泡不弹卡片,避免用户看到"同样选项又出现一次"。
|
||||
if _is_repeated_clarification(
|
||||
messages,
|
||||
new_question=question,
|
||||
new_options=cargs.get("options"),
|
||||
new_tool_call_id=clarification_call.get("id"),
|
||||
):
|
||||
logger.warning(
|
||||
"intent stream: dropping duplicate ask_clarification "
|
||||
"(question=%r) — already asked & answered earlier in thread",
|
||||
question[:80],
|
||||
)
|
||||
fallback_msg = (
|
||||
display_content
|
||||
or "(系统识别到智能体重复了上一问题,已自动跳过。请补充其他信息继续推进。)"
|
||||
)
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": "asking",
|
||||
"content": fallback_msg,
|
||||
"summary": None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": "clarification",
|
||||
"content": display_content,
|
||||
|
||||
@@ -764,25 +764,30 @@ def _find_trailing_ai_text(messages: list[dict[str, Any]], *, exclude_text: str
|
||||
|
||||
|
||||
def _find_dispatch_message(messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Locate the latest AIMessage that carries dispatch tool calls.
|
||||
"""Locate the latest AIMessage from THIS turn.
|
||||
|
||||
The very last message can be a skill-stop ToolMessage injected by
|
||||
``SkillStopMiddleware`` when it intercepts the dispatch, or a clarification
|
||||
ToolMessage. Either way, the actionable tool_calls live on the AIMessage
|
||||
*before* it, so we walk backwards until we hit one with ``tool_calls``.
|
||||
Returns the latest AIMessage when none of them carry tool_calls so the
|
||||
caller can still extract its text content.
|
||||
Trailing ToolMessages (skill_stop / clarification ack injected by
|
||||
``SkillStopMiddleware`` or ``ClarificationMiddleware``) are skipped because
|
||||
the actionable tool_calls live on the AIMessage that emitted them. We stop
|
||||
at the **first AIMessage encountered** — its ``tool_calls`` (or lack
|
||||
thereof) is the authoritative signal for this turn.
|
||||
|
||||
历史踩坑:之前实现是"一直往前翻直到撞到带 tool_calls 的 AI",看似优雅,
|
||||
实际上让 leader 永远收敛不了:本轮 LLM 不论怎么共识,只要历史上派过活,
|
||||
上一轮的派活 AI 就会被翻出来再用一次,前端会把同样的 task 又发给子智能体,
|
||||
陷入死循环。现在停在最新一条 AI 上,把判定交给调用方:`tool_calls=[]` →
|
||||
共识分支,有 tool_calls → 派活分支。
|
||||
"""
|
||||
last_ai: dict[str, Any] = {}
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
||||
if not last_ai:
|
||||
last_ai = msg
|
||||
if msg.get("tool_calls"):
|
||||
return msg
|
||||
return last_ai
|
||||
msg_type = (msg.get("type") or "").lower()
|
||||
if msg_type in ("tool", "toolmessage"):
|
||||
continue # 跳过末尾 skill_stop / clarification ack
|
||||
if msg_type in ("ai", "aimessage", "aimessagechunk"):
|
||||
return msg
|
||||
break # 撞到 Human/其他类型,本轮没 AI(异常路径,留空让上游报错)
|
||||
return {}
|
||||
|
||||
|
||||
async def _leader_run(
|
||||
|
||||
@@ -112,7 +112,7 @@ def _build_prompt(intent: dict[str, Any], candidates: list[dict[str, Any]]) -> s
|
||||
|
||||
def _validate_picks(
|
||||
raw_picks: Any,
|
||||
candidate_ids: set[str],
|
||||
candidates: list[dict[str, Any]],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Filter the model's picks against the candidate pool and clamp to 2-6.
|
||||
|
||||
@@ -121,18 +121,50 @@ def _validate_picks(
|
||||
model over-picked. Returns ``[]`` if validation leaves us with fewer than
|
||||
``_MIN_PICKS`` valid picks — the caller treats that as "fall back to
|
||||
defaults".
|
||||
|
||||
容错策略(2026-XX 修复 "用户自建 agent 被推荐丢失" 的问题):
|
||||
模型有时把候选池里的 ``name`` 当成 ``agent_id`` 回填(尤其当 id 是 UUID
|
||||
形式时, LLM 倾向用人类可读名),或者顺便也回了 ``name`` 字段。本函数:
|
||||
1. 先按 agent_id 命中候选池(严格匹配,原行为);
|
||||
2. 否则按 ``agent_id`` 的字面值反查 name → 找回真实 id;
|
||||
3. 否则按 ``name`` 字段反查 name → 找回真实 id。
|
||||
只要能定位到候选池里**唯一**一个 agent,就算 pick 成功。
|
||||
"""
|
||||
if not isinstance(raw_picks, list):
|
||||
return []
|
||||
|
||||
id_set: set[str] = set()
|
||||
name_to_id: dict[str, str] = {}
|
||||
for c in candidates:
|
||||
aid = str(c.get("agent_id") or "").strip()
|
||||
if not aid:
|
||||
continue
|
||||
id_set.add(aid)
|
||||
nm = str(c.get("name") or "").strip()
|
||||
if nm:
|
||||
# 大小写不敏感反查 —— 模型可能用空格 / 不同大小写 echo 中文名。
|
||||
name_to_id.setdefault(nm.lower(), aid)
|
||||
|
||||
picked: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_picks:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
aid = str(item.get("agent_id") or "").strip()
|
||||
raw_id = str(item.get("agent_id") or "").strip()
|
||||
raw_name = str(item.get("name") or "").strip()
|
||||
reason = str(item.get("reason") or "").strip()
|
||||
if not aid or aid not in candidate_ids or aid in seen:
|
||||
|
||||
aid = ""
|
||||
if raw_id and raw_id in id_set:
|
||||
aid = raw_id
|
||||
elif raw_id and raw_id.lower() in name_to_id:
|
||||
# 模型把"广告设计智能体"这种 name 当成 agent_id 回填了
|
||||
aid = name_to_id[raw_id.lower()]
|
||||
elif raw_name and raw_name.lower() in name_to_id:
|
||||
# 模型 agent_id 字段乱了,但 name 字段对得上
|
||||
aid = name_to_id[raw_name.lower()]
|
||||
|
||||
if not aid or aid in seen:
|
||||
continue
|
||||
picked.append({"agent_id": aid, "reason": reason})
|
||||
seen.add(aid)
|
||||
@@ -245,14 +277,30 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
|
||||
# 只扫描「最后一条非空 AIMessage」:推荐 agent 单轮输出,无多轮 READY 问题。
|
||||
picks: list[dict[str, str]] = []
|
||||
match = _RECOMMEND_READY_PATTERN.search(last_ai_content)
|
||||
# 诊断:打印模型输出末尾(含 RECOMMEND_READY 块)。grep "recommend stream debug"
|
||||
# 即可看到 LLM 实际推荐了什么、_validate_picks 解析前/后的差异。
|
||||
logger.info(
|
||||
"recommend stream debug | last_ai_tail=%r | match=%s | candidate_count=%d",
|
||||
last_ai_content[-600:] if last_ai_content else "",
|
||||
bool(match),
|
||||
len(candidates),
|
||||
)
|
||||
if match:
|
||||
try:
|
||||
parsed = json.loads(match.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("recommend picks JSON parse failed: %s", exc)
|
||||
logger.warning("recommend picks JSON parse failed: %s | raw=%r", exc, match.group(1)[:300])
|
||||
parsed = {}
|
||||
if isinstance(parsed, dict):
|
||||
picks = _validate_picks(parsed.get("picks"), candidate_ids)
|
||||
raw_picks = parsed.get("picks")
|
||||
# 传完整 candidates 列表(含 name) —— _validate_picks 容错按 name 反查 id,
|
||||
# 修复 LLM 把"广告设计智能体"这种 name 当 agent_id 回填导致全丢的问题。
|
||||
picks = _validate_picks(raw_picks, candidates)
|
||||
logger.info(
|
||||
"recommend stream debug | raw_picks=%r | validated_picks=%r",
|
||||
raw_picks,
|
||||
picks,
|
||||
)
|
||||
|
||||
# asking ≠ 模型在追问用户,而是「网关未能产出合法 picks」,由前端 fallback。
|
||||
status_value = "done" if picks else "asking"
|
||||
|
||||
@@ -58,16 +58,6 @@ models:
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: minimax-m2.5
|
||||
display_name: MiniMax M2.5
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: MiniMax-M2.5
|
||||
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
|
||||
base_url: https://api.minimaxi.com/v1
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: false
|
||||
|
||||
title:
|
||||
enabled: true
|
||||
|
||||
+64
-20
@@ -83,11 +83,16 @@ _SYSTEM_TEMPLATE = """你是一名写作助手。
|
||||
|
||||
_USER_TEMPLATE = """用户写作意图:{user_intent}
|
||||
|
||||
请分析并输出以下 JSON(如果意图中未明确某字段,给出合理默认值):
|
||||
用户在表单中已指定写作配置(以下三项必须原样输出,不要自行修改):
|
||||
- 文章类型(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:
|
||||
{{{{
|
||||
"article_type": "{type_options} 之一",
|
||||
"target_audience": "目标读者描述",
|
||||
"word_count_target": 800,
|
||||
"article_type": "{preconfigured_article_type}",
|
||||
"target_audience": "{preconfigured_audience}",
|
||||
"word_count_target": {preconfigured_word_count},
|
||||
"refined_intent": "精炼后的写作意图(一句话)"
|
||||
}}}}"""
|
||||
|
||||
@@ -124,6 +129,44 @@ def _coerce_word_count(raw) -> int | None:
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def _resolve_intent_fields(
|
||||
state: AIWritingState,
|
||||
result: dict,
|
||||
fallback_result: dict,
|
||||
valid_keys: list[str],
|
||||
default_key: str,
|
||||
) -> tuple[str, str, int, str]:
|
||||
"""合并 LLM 解析结果与用户表单预填字段;预填字段优先(见 AI_WRITING_BACKEND_DEV_ZH §intent_parser)。"""
|
||||
fallback_type = fallback_result["article_type"]
|
||||
if fallback_type not in valid_keys:
|
||||
fallback_type = default_key
|
||||
|
||||
raw_type = result.get("article_type", "")
|
||||
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:
|
||||
final_article_type = preconfigured_type
|
||||
else:
|
||||
final_article_type = llm_type
|
||||
|
||||
preconfigured_audience = (state.get("target_audience") or "").strip()
|
||||
if preconfigured_audience:
|
||||
final_target_audience = preconfigured_audience
|
||||
else:
|
||||
final_target_audience = result.get("target_audience") or fallback_result["target_audience"]
|
||||
|
||||
preconfigured_wc = state.get("word_count_target")
|
||||
if isinstance(preconfigured_wc, int) and preconfigured_wc > 0:
|
||||
final_word_count = preconfigured_wc
|
||||
else:
|
||||
word_count = _coerce_word_count(result.get("word_count_target"))
|
||||
final_word_count = word_count if word_count is not None else fallback_result["word_count_target"]
|
||||
|
||||
final_refined_intent = result.get("refined_intent") or state["user_intent"]
|
||||
return final_article_type, final_target_audience, final_word_count, final_refined_intent
|
||||
|
||||
|
||||
async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> dict:
|
||||
# 从 configurable 读取动态类型列表,未配置时退回默认值
|
||||
configurable = (config or {}).get("configurable", {})
|
||||
@@ -137,10 +180,6 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
|
||||
|
||||
type_options = _build_type_options(valid_article_types)
|
||||
system = _SYSTEM_TEMPLATE.format(type_options=type_options)
|
||||
user = _USER_TEMPLATE.format(
|
||||
user_intent=state["user_intent"],
|
||||
type_options=" | ".join(valid_keys),
|
||||
)
|
||||
|
||||
fallback_type = state.get("article_type", default_key)
|
||||
if fallback_type not in valid_keys:
|
||||
@@ -153,6 +192,17 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
|
||||
"refined_intent": state["user_intent"],
|
||||
}
|
||||
|
||||
preconfigured_wc = fallback_result["word_count_target"]
|
||||
if not isinstance(preconfigured_wc, int) or preconfigured_wc <= 0:
|
||||
preconfigured_wc = 800
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON,
|
||||
# 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果,
|
||||
# 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。
|
||||
@@ -193,13 +243,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
|
||||
"detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}",
|
||||
})
|
||||
|
||||
# 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值
|
||||
raw_type = result.get("article_type", "")
|
||||
final_article_type = raw_type if raw_type in valid_keys else fallback_type
|
||||
final_target_audience = result.get("target_audience") or fallback_result["target_audience"]
|
||||
word_count = _coerce_word_count(result.get("word_count_target"))
|
||||
final_word_count = word_count if word_count is not None else fallback_result["word_count_target"]
|
||||
final_refined_intent = result.get("refined_intent") or state["user_intent"]
|
||||
final_article_type, final_target_audience, final_word_count, final_refined_intent = _resolve_intent_fields(
|
||||
state, result, fallback_result, valid_keys, default_key
|
||||
)
|
||||
|
||||
# 推送结构化结果给前端 —— 结果卡片显示文章类型 / 目标读者 / 字数 / 精炼意图
|
||||
writer({
|
||||
@@ -214,11 +260,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
|
||||
|
||||
updates: dict = {"status": "researching", "progress_events": progress_events}
|
||||
updates["article_type"] = final_article_type
|
||||
if result.get("target_audience"):
|
||||
updates["target_audience"] = final_target_audience
|
||||
if word_count is not None:
|
||||
updates["word_count_target"] = final_word_count
|
||||
if result.get("refined_intent"):
|
||||
updates["target_audience"] = final_target_audience
|
||||
updates["word_count_target"] = final_word_count
|
||||
if final_refined_intent != state["user_intent"]:
|
||||
updates["user_intent"] = final_refined_intent
|
||||
|
||||
return updates
|
||||
+64
-10
@@ -16,18 +16,70 @@ from ._utils import get_model, _content_to_text, count_words, ThinkTagParser, sa
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 章节正文里的 Markdown 标题行(允许前导空白)
|
||||
_HEADING_PREFIX_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+", re.MULTILINE)
|
||||
# 章节正文里的 Markdown 标题行(允许前导空白) — 分组捕获缩进 + 井号串
|
||||
_HEADING_PREFIX_RE = re.compile(r"^([ \t]*)(#{1,6})[ \t]+", re.MULTILINE)
|
||||
|
||||
|
||||
def _normalize_section_headings(text: str) -> str:
|
||||
"""把章节正文里所有 Markdown 标题行统一压成三级标题(###)。
|
||||
"""把章节正文里的 Markdown 标题层级归一到允许的两级:### / ####。
|
||||
|
||||
作家被要求只用 ### 作为「要点」小标题;模型偶尔误用 #/##/#### 时在此纠正,
|
||||
保证文档标题层级符合「文章 # → 章节 ## → 要点 ###」,
|
||||
前端目录栏据此才能把要点正确识别为二级标题。
|
||||
文档层级:文章 `#` → 章节 `##` → 小节 `###` → 子要点 `####`。
|
||||
作家只输出小节(###)和子要点(####)两级,文章/章节标题由系统注入。
|
||||
|
||||
归一规则:
|
||||
- `#` / `##` → `###` 作家不应输出这两级,误用时降级为小节
|
||||
- `###` → `###` 保持
|
||||
- `####` → `####` 保持(三级结构的最深层)
|
||||
- `#####`+ → `####` 深度截断,防止结构过深
|
||||
"""
|
||||
return _HEADING_PREFIX_RE.sub("### ", text)
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
indent = match.group(1)
|
||||
level = len(match.group(2))
|
||||
# 三级及以下保持原级别(限 3 和 4);深度 4+ 压回 4
|
||||
new_level = 3 if level <= 3 else 4
|
||||
return f"{indent}{'#' * new_level} "
|
||||
|
||||
return _HEADING_PREFIX_RE.sub(_replace, text)
|
||||
|
||||
|
||||
def _render_key_points_for_prompt(key_points: list) -> str:
|
||||
"""把 outline 的 key_points 渲染成层级文本喂给作家。
|
||||
|
||||
输入是 List[KeyPoint] 或 List[str](旧 session 兼容)。输出形如:
|
||||
|
||||
- 小节标题1
|
||||
- 子要点 a
|
||||
- 子要点 b
|
||||
- 小节标题2(无子要点时只一行)
|
||||
|
||||
作家据此把每个主 bullet 写成 `### 小节标题`,把缩进的子要点
|
||||
用 `- ` bullet 列表落在小节正文起始处,再展开论述。
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for kp in key_points:
|
||||
if isinstance(kp, str):
|
||||
# 旧版兼容:整条当作小节标题,没有子要点
|
||||
title = kp.strip()
|
||||
if title:
|
||||
lines.append(f"- {title}")
|
||||
elif isinstance(kp, dict):
|
||||
title = str(
|
||||
kp.get("point_title")
|
||||
or kp.get("pointTitle")
|
||||
or kp.get("title")
|
||||
or ""
|
||||
).strip()
|
||||
if not title:
|
||||
continue
|
||||
lines.append(f"- {title}")
|
||||
subs = kp.get("sub_points") or kp.get("subPoints") or []
|
||||
if isinstance(subs, list):
|
||||
for sp in subs:
|
||||
sp_text = sp.strip() if isinstance(sp, str) else str(sp).strip()
|
||||
if sp_text:
|
||||
lines.append(f" - {sp_text}")
|
||||
return "\n".join(lines) or "- 主要内容"
|
||||
|
||||
|
||||
# 疑似「拒绝 / 元话语」的强特征短语:模型把对用户说的话当成正文输出时出现,
|
||||
@@ -166,7 +218,9 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
mat_ids = section.get("material_ids") or section.get("materialIds") or []
|
||||
|
||||
section_materials = _get_section_materials(mat_ids, pkg)
|
||||
key_points_str = "\n".join(f"- {p}" for p in key_points)
|
||||
# 三级结构:把每个 KeyPoint 展开成「小节标题 + 缩进子要点」喂给作家。
|
||||
# 兼容旧 session 里 key_points 为 List[str] 的情况(走相同 helper)。
|
||||
key_points_str = _render_key_points_for_prompt(key_points)
|
||||
|
||||
# 文章整体结构:让作家看到全文章节与自己的定位,避免章节间内容重复、引言结论脱节
|
||||
outline_context = "\n".join(
|
||||
@@ -282,8 +336,8 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
expand_msg = DRAFT_EXPAND_USER.format(
|
||||
article_title=article_title,
|
||||
section_title=section_titles[idx],
|
||||
key_points="\n".join(
|
||||
f"- {p}" for p in (target_sec.get("key_points") or target_sec.get("keyPoints") or [])
|
||||
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
|
||||
|
||||
+66
-6
@@ -7,7 +7,7 @@ import logging
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from ..state import AIWritingState, Outline, OutlineSection
|
||||
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
|
||||
from ..prompts.writer_prompts import (
|
||||
OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
|
||||
)
|
||||
@@ -27,16 +27,56 @@ def _materials_summary(pkg: dict) -> str:
|
||||
return "\n".join(lines) or "(无素材)"
|
||||
|
||||
|
||||
def _normalize_key_point(raw: object) -> KeyPoint:
|
||||
"""把 LLM 输出 / 旧 session 数据规整成 KeyPoint。
|
||||
|
||||
兼容场景:
|
||||
- 新版 dict:`{"point_title": "...", "sub_points": [...]}` —— 直接用,缺字段补默认
|
||||
- 旧版 string:历史 session 的 `key_points: ["要点1", "要点2"]` —— 自动 lift 成
|
||||
`{"point_title": "要点1", "sub_points": []}`,旧大纲打开后能继续编辑
|
||||
- LLM 偶尔会用 `title` / `subPoints` 这种 camelCase 或别名,做轻量兜底
|
||||
- 其它无法识别的形态(数字 / 列表 / None)→ 空 KeyPoint,不让整个大纲炸掉
|
||||
"""
|
||||
if isinstance(raw, str):
|
||||
return KeyPoint(point_title=raw, sub_points=[])
|
||||
if isinstance(raw, dict):
|
||||
# 字段别名兜底(LLM 时常 camelCase 或写 title):优先 snake_case
|
||||
title = (
|
||||
raw.get("point_title")
|
||||
or raw.get("pointTitle")
|
||||
or raw.get("title")
|
||||
or ""
|
||||
)
|
||||
subs_raw = raw.get("sub_points") or raw.get("subPoints") or []
|
||||
# 子要点统一为字符串数组;dict 子项取其 title 字段降级(避免 4 级嵌套混入)
|
||||
sub_points: list[str] = []
|
||||
if isinstance(subs_raw, list):
|
||||
for sp in subs_raw:
|
||||
if isinstance(sp, str):
|
||||
sub_points.append(sp)
|
||||
elif isinstance(sp, dict):
|
||||
sub_points.append(str(sp.get("point_title") or sp.get("title") or sp.get("text") or ""))
|
||||
return KeyPoint(point_title=str(title), sub_points=sub_points)
|
||||
# 其它意外类型(int / list / None):返回空 KeyPoint
|
||||
return KeyPoint(point_title="", sub_points=[])
|
||||
|
||||
|
||||
def _parse_outline(raw: dict) -> Outline:
|
||||
sections = []
|
||||
for s in raw.get("sections", []):
|
||||
raw_kps = s.get("key_points") or []
|
||||
key_points = [_normalize_key_point(kp) for kp in raw_kps]
|
||||
sections.append(OutlineSection(
|
||||
section_title=s.get("section_title", ""),
|
||||
key_points=s.get("key_points", []),
|
||||
key_points=key_points,
|
||||
material_ids=s.get("material_ids", []),
|
||||
))
|
||||
if not sections:
|
||||
sections = [OutlineSection(section_title="正文", key_points=["主要内容"], material_ids=[])]
|
||||
sections = [OutlineSection(
|
||||
section_title="正文",
|
||||
key_points=[KeyPoint(point_title="主要内容", sub_points=[])],
|
||||
material_ids=[],
|
||||
)]
|
||||
return Outline(title=raw.get("title", "未命名文章"), sections=sections)
|
||||
|
||||
|
||||
@@ -120,12 +160,32 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
|
||||
if is_revise:
|
||||
fallback = current_outline
|
||||
else:
|
||||
# 兜底大纲也用新的 3 级结构;_normalize_key_point 同时兼容旧 string 形态,
|
||||
# 这里给 dict 形态做样板。
|
||||
fallback = {
|
||||
"title": "未命名文章",
|
||||
"sections": [
|
||||
{"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []},
|
||||
{"section_title": "正文", "key_points": ["核心内容"], "material_ids": []},
|
||||
{"section_title": "结论", "key_points": ["总结"], "material_ids": []},
|
||||
{
|
||||
"section_title": "引言",
|
||||
"key_points": [
|
||||
{"point_title": "背景介绍", "sub_points": []},
|
||||
],
|
||||
"material_ids": [],
|
||||
},
|
||||
{
|
||||
"section_title": "正文",
|
||||
"key_points": [
|
||||
{"point_title": "核心内容", "sub_points": []},
|
||||
],
|
||||
"material_ids": [],
|
||||
},
|
||||
{
|
||||
"section_title": "结论",
|
||||
"key_points": [
|
||||
{"point_title": "总结", "sub_points": []},
|
||||
],
|
||||
"material_ids": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
result = parse_json(full_text, fallback)
|
||||
|
||||
+82
-34
@@ -1,10 +1,10 @@
|
||||
OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
你的目标:根据素材和意图,规划清晰的文章大纲。
|
||||
你的目标:根据素材和意图,规划清晰的三级大纲(章节 → 小节 → 子要点)。
|
||||
你的工作背景:你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。
|
||||
|
||||
你只能做以下事情:
|
||||
1. 分析素材,提取核心观点
|
||||
2. 规划文章章节结构和每节要点
|
||||
2. 规划文章三级结构:章节、章节下的小节、小节下的子要点
|
||||
你不能写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。
|
||||
|
||||
【严禁思考与表态】(最高优先级)
|
||||
@@ -12,10 +12,16 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
- 禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲");无论素材是否充分,都必须直接产出大纲 JSON。
|
||||
- 收到输入后立即、直接输出最终 JSON,不得有任何前置内容。
|
||||
|
||||
【要点(key_points)的特殊要求】
|
||||
- 每个要点都会作为文章里的一个二级小标题呈现,所以要点必须写成"标题式短语"。
|
||||
- 用简洁的名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。
|
||||
- 每个章节规划 2-4 个要点,不要过多,避免文章被切得过碎。
|
||||
【三级层级语义】(最高优先级,决定文章结构)
|
||||
- Level 1 = `section_title`(章节):将渲染为文章里的 `##` 二级标题,统领一组内容。
|
||||
- Level 2 = `point_title`(小节):将渲染为 `###` 三级标题,下面会有一段 2-3 句的概述/引入正文。
|
||||
- Level 3 = `sub_points`(子要点):将渲染为 `####` 四级标题,**每个子要点都是一个有自己 3-5 句独立正文段的小标题**——不是 bullet 列表。
|
||||
→ 因此子要点要规划成「能独立成段展开」的小主题,而不是一句话能讲完的零散点。
|
||||
|
||||
【三级字段的具体要求】
|
||||
- `section_title`(章节标题):高度概括的短语,6-15 字。每篇文章规划 3-5 个章节。
|
||||
- `point_title`(小节标题):名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。每个章节规划 2-4 个小节。
|
||||
- `sub_points`(子要点标题):名词性短语,<= 15 字,**不带句末标点**。每个小节规划 2-4 条子要点;如果该小节信息确实少到无法拆分子主题,可留 `[]`(此时只写小节级正文)。
|
||||
|
||||
【输出要求】
|
||||
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
|
||||
@@ -27,9 +33,31 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
{
|
||||
"title": "新能源汽车出口为何高速增长",
|
||||
"sections": [
|
||||
{"section_title": "引言:出口数据创新高", "key_points": ["出口量同比增幅", "全球市场排名变化"], "material_ids": ["m001"]},
|
||||
{"section_title": "增长背后的三大动因", "key_points": ["产业链成本优势", "海外政策利好", "产品竞争力提升"], "material_ids": ["m002", "m003"]},
|
||||
{"section_title": "结论与展望", "key_points": ["未来增长挑战", "行业趋势判断"], "material_ids": ["m004"]}
|
||||
{
|
||||
"section_title": "引言:出口数据创新高",
|
||||
"key_points": [
|
||||
{"point_title": "出口量同比增幅", "sub_points": ["2024 年累计出口规模", "同比增速对比 2023"]},
|
||||
{"point_title": "全球市场排名变化", "sub_points": ["对欧出口跃升", "对东盟首次反超日韩"]}
|
||||
],
|
||||
"material_ids": ["m001"]
|
||||
},
|
||||
{
|
||||
"section_title": "增长背后的三大动因",
|
||||
"key_points": [
|
||||
{"point_title": "产业链成本优势", "sub_points": ["电池原料端议价能力", "整车规模效应"]},
|
||||
{"point_title": "海外政策利好", "sub_points": ["欧盟碳关税倒逼", "中东购车补贴"]},
|
||||
{"point_title": "产品竞争力提升", "sub_points": ["智能化配置溢价", "出海车型迭代速度"]}
|
||||
],
|
||||
"material_ids": ["m002", "m003"]
|
||||
},
|
||||
{
|
||||
"section_title": "结论与展望",
|
||||
"key_points": [
|
||||
{"point_title": "未来增长挑战", "sub_points": ["欧盟反补贴调查", "海外渠道建设成本"]},
|
||||
{"point_title": "行业趋势判断", "sub_points": []}
|
||||
],
|
||||
"material_ids": ["m004"]
|
||||
}
|
||||
]
|
||||
}"""
|
||||
|
||||
@@ -46,7 +74,7 @@ OUTLINE_USER = """写作意图:{user_intent}
|
||||
# ── 重新规划(基于现有大纲的编辑)─────────────────────────────────────────────
|
||||
|
||||
OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
你的目标:根据用户的修改要求,调整一份【已有的文章大纲】。
|
||||
你的目标:根据用户的修改要求,调整一份【已有的三级文章大纲】。
|
||||
你的工作背景:你有十年内容创作经验,擅长精准理解修改意图并落实到大纲结构上。
|
||||
|
||||
你只能做以下事情:
|
||||
@@ -54,25 +82,31 @@ OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
你不能写正文,只输出调整后的大纲。
|
||||
|
||||
【修改原则】(最高优先级)
|
||||
- 准确识别并落实用户意图,常见操作:新增章节、删除章节、合并章节、拆分章节、调整章节顺序、修改章节标题、调整章节要点。
|
||||
- 只改用户明确提到的部分:用户没要求改动的章节,必须原样保留(标题、要点、material_ids 全部不变)。
|
||||
- 新增章节时:紧扣写作意图补充合理章节,并尽量关联可用素材的 material_ids。
|
||||
- 章节数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
|
||||
- 准确识别并落实用户意图,常见操作:新增/删除/合并/拆分章节或小节、调整顺序、修改标题、调整子要点。
|
||||
- 只改用户明确提到的部分:用户没要求改动的章节、小节、子要点必须原样保留(标题、内容、material_ids 全部不变)。
|
||||
- 新增内容时:紧扣写作意图,并尽量关联可用素材的 material_ids。
|
||||
- 章节/小节/子要点数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。
|
||||
|
||||
【严禁思考与表态】(最高优先级)
|
||||
- 禁止输出任何思考、分析、规划过程,禁止 <think></think> 标签及其中内容。
|
||||
- 禁止输出任何对用户说的话;收到输入后立即、直接输出最终 JSON。
|
||||
|
||||
【要点(key_points)的特殊要求】
|
||||
- 每个要点会作为文章里的二级小标题呈现,写成简洁名词性短语,15 字以内,不带句末标点。
|
||||
- 每个章节 2-4 个要点。
|
||||
【三级层级语义】(最高优先级)
|
||||
- `section_title`(章节,`##` 二级标题):高度概括的短语,6-15 字。
|
||||
- `point_title`(小节,`###` 三级标题):名词性短语,15 字以内,无句末标点;小节下会有一段概述正文。每章节 2-4 个小节。
|
||||
- `sub_points`(子要点,`####` 四级标题):名词性短语,<= 15 字,无句末标点;**每条子要点会展开成一段独立正文**,所以要写成能成段的主题。每小节 2-4 条,信息少到无法拆时可留 `[]`。
|
||||
|
||||
【兼容历史大纲】
|
||||
- 若「当前大纲」里的 `key_points` 是字符串数组(旧版 2 级结构),保留原值视为小节标题,
|
||||
在补充 `sub_points: []` 后输出新格式:`{"point_title": "<原 string>", "sub_points": []}`。
|
||||
- 用户修改时如要求"为某要点补充更详细的子项",就把对应小节的 sub_points 填上。
|
||||
|
||||
【输出要求】
|
||||
- 只输出一个调整后的【完整】大纲 JSON 对象,不要解释文字,不要 markdown 代码块。
|
||||
- 必须使用英文双引号 "。
|
||||
- material_ids 中的 ID 必须来自给定素材,没有合适素材时填 []。
|
||||
- 结构与「当前大纲」一致:
|
||||
{"title": "...", "sections": [{"section_title": "...", "key_points": ["..."], "material_ids": ["..."]}]}"""
|
||||
- 结构(嵌套三级):
|
||||
{"title": "...", "sections": [{"section_title": "...", "key_points": [{"point_title": "...", "sub_points": ["..."]}], "material_ids": ["..."]}]}"""
|
||||
|
||||
OUTLINE_REVISE_USER = """写作意图:{user_intent}
|
||||
文章类型:{article_type}
|
||||
@@ -104,18 +138,23 @@ DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
- 禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材""素材与意图不匹配"等——这类话绝不允许出现在文章里。
|
||||
- 无论素材是否充分,你都必须直接产出文章正文本身;收到输入后立即开始写正文(含 ### 小标题),不要有任何前置说明。
|
||||
|
||||
【结构要求】
|
||||
- 把本章节的每一个「章节要点」写成一个二级小标题,用 Markdown 三级标题语法:### 要点
|
||||
- 每个 ### 小标题下面紧跟该要点的论述正文,正文连贯成段。
|
||||
- 即使章节只有一个要点,也要为它写出对应的 ### 小标题。
|
||||
- 小标题文字简洁,直接沿用给定的要点措辞,不要加序号、不要加标点。
|
||||
- 只能使用 ### 这一级标题;绝不要输出 # 或 ##(文章标题、章节标题由系统统一添加)。
|
||||
【结构要求】(三级结构,必须严格遵守)
|
||||
- 「章节要点」用层级 bullet 列表给出:
|
||||
- 主要 bullet(顶格)= 小节标题,对应 Markdown `###` 三级标题
|
||||
- 缩进 bullet(带前导空格)= 该小节下的子要点,对应 Markdown `####` 四级标题
|
||||
- **每个小节、每个子要点都要有自己的正文段,分两层展开**:
|
||||
1. `### 小节标题` 下先写一段「概述/引入正文」(2-3 句),说明本小节要讨论什么、几个子要点之间的关系。
|
||||
2. 然后对每个子要点:`#### 子要点标题` 单起一行,再展开 3-5 句具体论述(结合素材中的事实/数据)。
|
||||
- 若小节**没有子要点**(缩进 bullet 为空):在 `### 小节标题` 下直接连贯成段论述,**不出现 ####**。
|
||||
- 即使章节只有一个小节,也要为它写出对应的 `###` 标题。
|
||||
- 标题层级严格对应:文章 `#` / 章节 `##`(系统注入,作家**不要输出**)/ 小节 `###` / 子要点 `####`。
|
||||
- 不要输出 `#####` 或更深;这种深度不在结构里。
|
||||
|
||||
【输出要求】
|
||||
- 直接返回这一段的内容(含 ### 小标题与正文),不要 JSON,不要解释。
|
||||
- 直接返回这一段的内容(含 `### 小节标题` + 概述段 + `#### 子要点标题` + 各自正文),不要 JSON,不要解释。
|
||||
- 不要写"以下是正文""本段内容如下"之类的开场白。
|
||||
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
|
||||
- 充分展开每个要点:每个要点至少用 2-3 句话论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
|
||||
- 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
|
||||
|
||||
# ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ───────────────────
|
||||
|
||||
@@ -141,17 +180,25 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
|
||||
{outline_context}
|
||||
|
||||
当前章节:{section_title}
|
||||
章节要点:{key_points}
|
||||
章节内的小节与子要点(层级 bullet 列表):
|
||||
{key_points}
|
||||
|
||||
相关素材:
|
||||
{section_materials}
|
||||
{revision_section}{mode_section}
|
||||
请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。
|
||||
把上述每个章节要点写成一个 ### 二级小标题,并在标题下展开论述,论述充分、内容充实。"""
|
||||
|
||||
按下面的结构两层展开写:
|
||||
- 每个主要 bullet(顶格)→ `### 小节标题`,标题下先写 2-3 句概述/引入正文。
|
||||
- 该小节的每个缩进 bullet(子要点)→ `#### 子要点标题`,标题下展开 3-5 句具体论述。
|
||||
- 没有缩进 bullet 的小节,直接在 ### 标题下连贯成段论述,不出现 ####。
|
||||
|
||||
论述充分、层次清晰、内容充实。"""
|
||||
|
||||
DRAFT_EXPAND_USER = """文章标题:{article_title}
|
||||
当前章节:{section_title}
|
||||
章节要点:{key_points}
|
||||
章节内的小节与子要点(层级 bullet 列表):
|
||||
{key_points}
|
||||
|
||||
相关素材:
|
||||
{section_materials}
|
||||
@@ -159,8 +206,9 @@ DRAFT_EXPAND_USER = """文章标题:{article_title}
|
||||
这一章节已有内容如下:
|
||||
{existing_text}
|
||||
|
||||
以上内容篇幅不足。请在保留原意、事实和 ### 二级小标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。
|
||||
以上内容篇幅不足。请在保留原意、事实和「### 小节 / #### 子要点」两级标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。
|
||||
要求:
|
||||
- 补充更多来自素材的事实、数据与分析,论述更充分、更具体。
|
||||
- 保留并沿用原有的 ### 小标题(每个章节要点对应一个),只用 ### 这一级标题,不要输出 # 或 ##。
|
||||
- 直接返回扩充后的完整章节内容(含 ### 小标题),不要解释。"""
|
||||
- 补充更多来自素材的事实、数据与分析,让每一层都论述更充分、更具体。
|
||||
- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
|
||||
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
|
||||
- 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
|
||||
+14
-1
@@ -20,10 +20,23 @@ class MaterialPackage(TypedDict):
|
||||
summary: str
|
||||
|
||||
# ── 大纲 ──────────────────────────────────────────────────────────────────────
|
||||
# 三级结构:章节(##) → 小节(###) → 子要点(bullet -)。
|
||||
# Outline.sections[] = Level 1 章节(渲染为 ## 由系统注入)
|
||||
# OutlineSection.key_points[] = Level 2 小节(渲染为 ### 标题)
|
||||
# KeyPoint.sub_points[] = Level 3 子要点(bullet "- ...",非标题)
|
||||
#
|
||||
# 历史上 key_points 曾是 List[str](2 级):每个 str 既是小节标题又是渲染单位。
|
||||
# 升级到 3 级后,key_points 变成 List[KeyPoint],每个 KeyPoint 自带 sub_points
|
||||
# 列表;旧 session 里的 string 项由 writer_outline._parse_outline 自动 lift 成
|
||||
# {point_title: <原 string>, sub_points: []},无需迁移数据。
|
||||
|
||||
class KeyPoint(TypedDict):
|
||||
point_title: str # 小节标题(渲染为 ### 二级小标题)
|
||||
sub_points: List[str] # 子要点(可为空;非空时在小节正文里以 bullet 列表呈现)
|
||||
|
||||
class OutlineSection(TypedDict):
|
||||
section_title: str
|
||||
key_points: List[str]
|
||||
key_points: List[KeyPoint] # 注意:旧版是 List[str],解析层做了兼容
|
||||
material_ids: List[str]
|
||||
|
||||
class Outline(TypedDict):
|
||||
|
||||
+45
-35
@@ -7,10 +7,10 @@ from langchain_core.runnables import RunnableConfig
|
||||
from deerflow.agents.lead_agent.prompt import apply_prompt_template
|
||||
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
||||
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
|
||||
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
||||
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
|
||||
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
||||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||||
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
||||
@@ -253,16 +253,16 @@ def _build_middlewares(
|
||||
|
||||
Returns:
|
||||
List of middleware instances.
|
||||
"""
|
||||
resolved_app_config = app_config or get_app_config()
|
||||
cfg = _get_runtime_config(config)
|
||||
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
|
||||
"""
|
||||
resolved_app_config = app_config or get_app_config()
|
||||
cfg = _get_runtime_config(config)
|
||||
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
|
||||
|
||||
# Inject the admin prompt prefix into the model request (kept out of the
|
||||
# persisted/displayed message so the user never sees it).
|
||||
middlewares.append(PromptPrefixMiddleware())
|
||||
if cfg.get("rag_mode_enabled", False):
|
||||
middlewares.append(RagCitationMiddleware())
|
||||
# Inject the admin prompt prefix into the model request (kept out of the
|
||||
# persisted/displayed message so the user never sees it).
|
||||
middlewares.append(PromptPrefixMiddleware())
|
||||
if cfg.get("rag_mode_enabled", False):
|
||||
middlewares.append(RagCitationMiddleware())
|
||||
|
||||
# Add summarization middleware if enabled
|
||||
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
||||
@@ -270,7 +270,7 @@ def _build_middlewares(
|
||||
middlewares.append(summarization_middleware)
|
||||
|
||||
# Add TodoList middleware if plan mode is enabled
|
||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
||||
if todo_list_middleware is not None:
|
||||
@@ -284,9 +284,9 @@ def _build_middlewares(
|
||||
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
|
||||
|
||||
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow)
|
||||
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
|
||||
if not is_bootstrap:
|
||||
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
|
||||
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
|
||||
if not is_bootstrap:
|
||||
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
|
||||
|
||||
# Add ViewImageMiddleware only if the current model supports vision.
|
||||
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
|
||||
@@ -372,10 +372,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||
subagent_enabled = cfg.get("subagent_enabled", False)
|
||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||
is_bootstrap = cfg.get("is_bootstrap", 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))
|
||||
is_bootstrap = cfg.get("is_bootstrap", 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))
|
||||
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
|
||||
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
|
||||
writing_artifact_path = None
|
||||
@@ -464,23 +464,31 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
available_skills=None,
|
||||
is_bootstrap=True,
|
||||
app_config=resolved_app_config,
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
rag_mode_enabled=rag_mode_enabled,
|
||||
),
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
rag_mode_enabled=rag_mode_enabled,
|
||||
),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
# Default lead agent (unchanged behavior)
|
||||
is_notebook_mode = bool(cfg.get("notebook_search_space_id"))
|
||||
notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or []
|
||||
default_tools = get_available_tools(
|
||||
model_name=model_name,
|
||||
groups=agent_config.tool_groups if agent_config else None,
|
||||
subagent_enabled=subagent_enabled,
|
||||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||||
excluded_tools=requested_excluded_tools,
|
||||
app_config=resolved_app_config,
|
||||
)
|
||||
if is_notebook_mode:
|
||||
from deerflow.tools.builtins.notebook_search_tool import notebook_search_tool
|
||||
|
||||
default_tools = [notebook_search_tool] + default_tools
|
||||
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
|
||||
tools=get_available_tools(
|
||||
model_name=model_name,
|
||||
groups=agent_config.tool_groups if agent_config else None,
|
||||
subagent_enabled=subagent_enabled,
|
||||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||||
excluded_tools=requested_excluded_tools,
|
||||
app_config=resolved_app_config,
|
||||
),
|
||||
tools=default_tools,
|
||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
||||
system_prompt=apply_prompt_template(
|
||||
subagent_enabled=subagent_enabled,
|
||||
@@ -491,8 +499,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
writing_artifact_path=writing_artifact_path,
|
||||
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
||||
app_config=resolved_app_config,
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
rag_mode_enabled=rag_mode_enabled,
|
||||
),
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
rag_mode_enabled=rag_mode_enabled,
|
||||
is_notebook_mode=is_notebook_mode,
|
||||
notebook_source_ids=notebook_source_ids,
|
||||
),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
+155
-111
@@ -665,28 +665,28 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _get_cached_skills_prompt_section(
|
||||
skill_signature: tuple[tuple[str, str, str, str], ...],
|
||||
display_signature: tuple[tuple[str, str, str], ...],
|
||||
available_skills_key: tuple[str, ...] | None,
|
||||
container_base_path: str,
|
||||
skill_evolution_section: str,
|
||||
) -> str:
|
||||
display_by_skill = {name: (mode, custom_instruction) for name, mode, custom_instruction in display_signature}
|
||||
filtered = [(name, description, category, location) for name, description, category, location in skill_signature if available_skills_key is None or name in available_skills_key]
|
||||
skills_list = ""
|
||||
if filtered:
|
||||
items = []
|
||||
for name, description, category, location in filtered:
|
||||
display_mode, custom_instruction = display_by_skill.get(name, ("", ""))
|
||||
display_hint = ""
|
||||
if display_mode:
|
||||
display_hint = f"\n <display mode=\"{display_mode}\">{custom_instruction}</display>"
|
||||
items.append(
|
||||
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>{display_hint}\n </skill>"
|
||||
)
|
||||
skill_items = "\n".join(items)
|
||||
skills_list = f"<available_skills>\n{skill_items}\n</available_skills>"
|
||||
def _get_cached_skills_prompt_section(
|
||||
skill_signature: tuple[tuple[str, str, str, str], ...],
|
||||
display_signature: tuple[tuple[str, str, str], ...],
|
||||
available_skills_key: tuple[str, ...] | None,
|
||||
container_base_path: str,
|
||||
skill_evolution_section: str,
|
||||
) -> str:
|
||||
display_by_skill = {name: (mode, custom_instruction) for name, mode, custom_instruction in display_signature}
|
||||
filtered = [(name, description, category, location) for name, description, category, location in skill_signature if available_skills_key is None or name in available_skills_key]
|
||||
skills_list = ""
|
||||
if filtered:
|
||||
items = []
|
||||
for name, description, category, location in filtered:
|
||||
display_mode, custom_instruction = display_by_skill.get(name, ("", ""))
|
||||
display_hint = ""
|
||||
if display_mode:
|
||||
display_hint = f"\n <display mode=\"{display_mode}\">{custom_instruction}</display>"
|
||||
items.append(
|
||||
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>{display_hint}\n </skill>"
|
||||
)
|
||||
skill_items = "\n".join(items)
|
||||
skills_list = f"<available_skills>\n{skill_items}\n</available_skills>"
|
||||
|
||||
# Restricted mode: agent has an explicit skill allowlist.
|
||||
# We deliberately omit the "Skills are located at: <path>" hint and the
|
||||
@@ -698,44 +698,44 @@ def _get_cached_skills_prompt_section(
|
||||
|
||||
if is_restricted:
|
||||
scope_note = "\nYou have access ONLY to the skills listed below. Do NOT attempt to read, list, or invoke any skill that is not in this list, even if you remember other skill paths.\n"
|
||||
else:
|
||||
scope_note = ""
|
||||
|
||||
display_pattern = (
|
||||
"""
|
||||
**Skill Result Display Pattern:**
|
||||
- If a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[[1]]`, the second as `[[2]]`, and so on. Use only numbers that correspond to returned results.
|
||||
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
|
||||
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
|
||||
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
|
||||
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
|
||||
"""
|
||||
if display_signature
|
||||
else ""
|
||||
)
|
||||
|
||||
return f"""<skill_system>
|
||||
You have access to skills that provide optimized workflows for specific tasks. Each skill contains best practices, frameworks, and references to additional resources.
|
||||
{scope_note}
|
||||
**Progressive Loading Pattern:**
|
||||
1. When a user query matches a skill's use case, immediately call `read_file` on the skill's main file using the path attribute provided in the skill tag below
|
||||
else:
|
||||
scope_note = ""
|
||||
|
||||
display_pattern = (
|
||||
"""
|
||||
**Skill Result Display Pattern:**
|
||||
- If a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[[1]]`, the second as `[[2]]`, and so on. Use only numbers that correspond to returned results.
|
||||
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
|
||||
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
|
||||
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
|
||||
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
|
||||
"""
|
||||
if display_signature
|
||||
else ""
|
||||
)
|
||||
|
||||
return f"""<skill_system>
|
||||
You have access to skills that provide optimized workflows for specific tasks. Each skill contains best practices, frameworks, and references to additional resources.
|
||||
{scope_note}
|
||||
**Progressive Loading Pattern:**
|
||||
1. When a user query matches a skill's use case, immediately call `read_file` on the skill's main file using the path attribute provided in the skill tag below
|
||||
2. Read and understand the skill's workflow and instructions
|
||||
3. The skill file contains references to external resources under the same folder
|
||||
4. Load referenced resources only when needed during execution
|
||||
5. Follow the skill's instructions precisely
|
||||
{display_pattern}
|
||||
{location_hint}{evolution_block}
|
||||
{skills_list}
|
||||
|
||||
</skill_system>"""
|
||||
|
||||
|
||||
def get_skills_prompt_section(
|
||||
available_skills: set[str] | None = None,
|
||||
*,
|
||||
app_config: AppConfig | None = None,
|
||||
display_enabled: bool = False,
|
||||
) -> str:
|
||||
3. The skill file contains references to external resources under the same folder
|
||||
4. Load referenced resources only when needed during execution
|
||||
5. Follow the skill's instructions precisely
|
||||
{display_pattern}
|
||||
{location_hint}{evolution_block}
|
||||
{skills_list}
|
||||
|
||||
</skill_system>"""
|
||||
|
||||
|
||||
def get_skills_prompt_section(
|
||||
available_skills: set[str] | None = None,
|
||||
*,
|
||||
app_config: AppConfig | None = None,
|
||||
display_enabled: bool = False,
|
||||
) -> str:
|
||||
"""Generate the skills prompt section with available skills list."""
|
||||
skills = _get_enabled_skills_for_config(app_config)
|
||||
|
||||
@@ -765,31 +765,31 @@ def get_skills_prompt_section(
|
||||
if available_skills is not None and not any(skill.name in available_skills for skill in skills):
|
||||
return ""
|
||||
|
||||
skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path)) for skill in skills)
|
||||
display_signature: tuple[tuple[str, str, str], ...] = ()
|
||||
if display_enabled:
|
||||
try:
|
||||
from deerflow.config.extensions_config import get_extensions_config
|
||||
|
||||
extensions = get_extensions_config()
|
||||
display_signature = tuple(
|
||||
(
|
||||
name,
|
||||
skill_config.display.mode,
|
||||
skill_config.display.custom_instruction,
|
||||
)
|
||||
for name, skill_config in sorted(extensions.skills.items())
|
||||
if skill_config.display is not None
|
||||
and skill_config.display.enabled
|
||||
and skill_config.display.mode != "none"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to load skill display configs for prompt injection", exc_info=True)
|
||||
available_key = tuple(sorted(available_skills)) if available_skills is not None else None
|
||||
if not skill_signature and available_key is not None:
|
||||
return ""
|
||||
skill_evolution_section = _build_skill_evolution_section(effective_skill_evolution_enabled)
|
||||
return _get_cached_skills_prompt_section(skill_signature, display_signature, available_key, container_base_path, skill_evolution_section)
|
||||
skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path)) for skill in skills)
|
||||
display_signature: tuple[tuple[str, str, str], ...] = ()
|
||||
if display_enabled:
|
||||
try:
|
||||
from deerflow.config.extensions_config import get_extensions_config
|
||||
|
||||
extensions = get_extensions_config()
|
||||
display_signature = tuple(
|
||||
(
|
||||
name,
|
||||
skill_config.display.mode,
|
||||
skill_config.display.custom_instruction,
|
||||
)
|
||||
for name, skill_config in sorted(extensions.skills.items())
|
||||
if skill_config.display is not None
|
||||
and skill_config.display.enabled
|
||||
and skill_config.display.mode != "none"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to load skill display configs for prompt injection", exc_info=True)
|
||||
available_key = tuple(sorted(available_skills)) if available_skills is not None else None
|
||||
if not skill_signature and available_key is not None:
|
||||
return ""
|
||||
skill_evolution_section = _build_skill_evolution_section(effective_skill_evolution_enabled)
|
||||
return _get_cached_skills_prompt_section(skill_signature, display_signature, available_key, container_base_path, skill_evolution_section)
|
||||
|
||||
|
||||
def get_agent_soul(agent_id: str | None) -> str:
|
||||
@@ -912,10 +912,12 @@ def apply_prompt_template(
|
||||
writing_artifact_path: str | None = None,
|
||||
available_skills: set[str] | None = None,
|
||||
is_bootstrap: bool = False,
|
||||
app_config: AppConfig | None = None,
|
||||
memory_injection_enabled: bool = True,
|
||||
rag_mode_enabled: bool = False,
|
||||
) -> str:
|
||||
app_config: AppConfig | None = None,
|
||||
memory_injection_enabled: bool = True,
|
||||
rag_mode_enabled: bool = False,
|
||||
is_notebook_mode: bool = False,
|
||||
notebook_source_ids: list[int] | None = None,
|
||||
) -> str:
|
||||
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||
|
||||
# Get memory context
|
||||
@@ -943,15 +945,15 @@ def apply_prompt_template(
|
||||
else ""
|
||||
)
|
||||
|
||||
# Bootstrap section: skill-matching guidance (only in agent creation flow)
|
||||
# Bootstrap section: skill-matching guidance (only in agent creation flow)
|
||||
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
|
||||
|
||||
# Get skills section
|
||||
skills_section = get_skills_prompt_section(
|
||||
available_skills,
|
||||
app_config=app_config,
|
||||
display_enabled=rag_mode_enabled,
|
||||
)
|
||||
skills_section = get_skills_prompt_section(
|
||||
available_skills,
|
||||
app_config=app_config,
|
||||
display_enabled=rag_mode_enabled,
|
||||
)
|
||||
|
||||
# Get deferred tools section (tool_search)
|
||||
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
|
||||
@@ -988,22 +990,22 @@ Writing mode is enabled for this run.
|
||||
acp_and_mounts_section = "\n".join(section for section in (acp_section, custom_mounts_section) if section)
|
||||
|
||||
# Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set).
|
||||
network_policy_section = _build_network_policy_section()
|
||||
rag_mode_section = (
|
||||
"""
|
||||
<rag_citation_mode>
|
||||
RAG/reference display mode is enabled.
|
||||
When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
- If the skill has a display adapter, follow that adapter first.
|
||||
- For `citation`, cite returned items with `[[1]]`, `[[2]]`, etc. Only cite real returned items.
|
||||
- For `table`, do not hand-write the table. Summarize and place `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the table should appear. The frontend will render the real tool result as a stable table.
|
||||
- For `list` or `card`, summarize and place `<skill-display tool="SKILL_OR_TOOL_NAME" mode="list" />` or `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the structured result should appear.
|
||||
- If no display adapter exists, default to citation mode with numbered citations.
|
||||
</rag_citation_mode>
|
||||
"""
|
||||
if rag_mode_enabled
|
||||
else ""
|
||||
)
|
||||
network_policy_section = _build_network_policy_section()
|
||||
rag_mode_section = (
|
||||
"""
|
||||
<rag_citation_mode>
|
||||
RAG/reference display mode is enabled.
|
||||
When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
- If the skill has a display adapter, follow that adapter first.
|
||||
- For `citation`, cite returned items with `[[1]]`, `[[2]]`, etc. Only cite real returned items.
|
||||
- For `table`, do not hand-write the table. Summarize and place `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the table should appear. The frontend will render the real tool result as a stable table.
|
||||
- For `list` or `card`, summarize and place `<skill-display tool="SKILL_OR_TOOL_NAME" mode="list" />` or `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the structured result should appear.
|
||||
- If no display adapter exists, default to citation mode with numbered citations.
|
||||
</rag_citation_mode>
|
||||
"""
|
||||
if rag_mode_enabled
|
||||
else ""
|
||||
)
|
||||
|
||||
# Format the prompt with dynamic skills and memory
|
||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||
@@ -1021,4 +1023,46 @@ When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
acp_section=acp_and_mounts_section,
|
||||
)
|
||||
|
||||
return prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
||||
prompt = prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
||||
|
||||
if is_notebook_mode:
|
||||
source_ids = notebook_source_ids or []
|
||||
if source_ids:
|
||||
source_line = f"当前检索范围:{len(source_ids)} 个来源(IDs: {', '.join(str(i) for i in source_ids)})"
|
||||
else:
|
||||
source_line = "当前检索范围:全部来源"
|
||||
|
||||
prompt += f"""
|
||||
|
||||
<notebook_mode>
|
||||
## 角色
|
||||
你是一个笔记本 AI 助手,**只能**基于笔记本文档来源回答用户问题,禁止使用任何其他知识。
|
||||
|
||||
## 可用知识来源
|
||||
{source_line}
|
||||
|
||||
## 强制工作流程(每次用户提问都必须严格执行,无例外)
|
||||
1. **第一步(必须)**:立即调用 `search_notebook_sources` 工具,`query` 填写用户问题或精炼的检索关键词。
|
||||
- 无论问题多简单,都必须先检索,不得跳过。
|
||||
- 若检索结果不足,可再次调用不同 query 补充检索。
|
||||
2. **第二步**:仅基于检索到的内容组织回答,**不得使用训练知识或推断**。
|
||||
3. **第三步**:若检索结果中确实无相关内容,明确告知用户"暂未在来源中找到相关信息"。
|
||||
|
||||
## 引用格式(强制)
|
||||
- 每个事实/句子后立即插入 `[[cite:<chunk_id>]]` 标记,chunk_id 来自检索结果中每个文本块的 id 字段。
|
||||
- 多个来源支持同一句话时连续插入:`[[cite:101]][[cite:102]]`
|
||||
- 引用标记紧跟被引用内容末尾,不加空格,不单独成行。
|
||||
|
||||
## 示例
|
||||
用户:合同中违约金是多少?
|
||||
(调用 search_notebook_sources query="违约金",返回 chunk_id=101 的结果)
|
||||
回答:根据合同第五条,违约金为合同总额的 0.5%[[cite:101]],乙方拒绝收货视为违约[[cite:101]]。
|
||||
|
||||
## 绝对禁止
|
||||
- 未调用 `search_notebook_sources` 就直接回答
|
||||
- 引用不在检索结果中的 chunk_id
|
||||
- 省略引用标记
|
||||
- 使用笔记本来源之外的知识
|
||||
</notebook_mode>"""
|
||||
|
||||
return prompt
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
"""笔记本 RAG 检索工具。
|
||||
|
||||
从 LangGraph configurable 中读取 notebook_search_space_id / notebook_source_ids /
|
||||
notebook_token,调用外部检索接口返回相关文本块,供 LLM 在笔记本模式下引用回答。
|
||||
|
||||
API base URL 通过环境变量 DEER_FLOW_NOTEBOOK_API_BASE 配置。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
from langgraph.config import get_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
|
||||
|
||||
|
||||
def _notebook_api_base() -> str:
|
||||
return (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/")
|
||||
|
||||
|
||||
@tool("search_notebook_sources", parse_docstring=True)
|
||||
def notebook_search_tool(query: str, top_k: int = 10) -> str:
|
||||
"""Search the notebook's knowledge sources to find relevant content.
|
||||
|
||||
Call this tool for EVERY user question. After retrieving results, cite
|
||||
sources inline using [[cite:<document_id>]] markers immediately after
|
||||
each claim, e.g. "违约金为0.5%[[cite:101]]".
|
||||
|
||||
Args:
|
||||
query: The user's question or a refined search query.
|
||||
top_k: Number of top chunks to retrieve (default 10).
|
||||
"""
|
||||
base = _notebook_api_base()
|
||||
if not base:
|
||||
return "Notebook API base URL not configured (DEER_FLOW_NOTEBOOK_API_BASE)."
|
||||
|
||||
cfg = get_config().get("configurable", {})
|
||||
search_space_id: int | None = cfg.get("notebook_search_space_id")
|
||||
source_ids: list[int] = cfg.get("notebook_source_ids") or []
|
||||
token: str = cfg.get("notebook_token") or ""
|
||||
|
||||
if not search_space_id:
|
||||
return "No notebook search space configured."
|
||||
|
||||
logger.info("notebook_search_tool query=%r source_ids=%s search_space_id=%s", query, source_ids, search_space_id)
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{base}{_SEARCH_PATH}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"query": query,
|
||||
"source_ids": [source_ids],
|
||||
"search_space_id": search_space_id,
|
||||
"top_k": top_k,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = data.get("content") if isinstance(data, dict) else None
|
||||
return content if content else "No relevant content found."
|
||||
except Exception as e:
|
||||
logger.warning("notebook_search_tool failed: %s", e)
|
||||
return f"Search failed: {e}"
|
||||
Reference in new issue
Block a user