595 lines
21 KiB
Python
595 lines
21 KiB
Python
"""GoalPlanner — 目标分析与计划生成
|
||
|
||
用户给定自然语言目标后,自动生成结构化执行计划,包含任务拆解、
|
||
依赖关系、并行度识别。作为 Orchestrator._decompose_task() 的前置增强层。
|
||
|
||
执行流程:
|
||
1. 通过结构化目标分解(规则/模板)生成初始方案
|
||
2. 如果初始方案有效则跳过 LLM 调用
|
||
3. 否则将初始方案作为上下文注入 LLM prompt,LLM 细化调整
|
||
4. 识别能力缺口,请求人工介入
|
||
5. 通过 AskHumanTool 请求确认/修改
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
import uuid
|
||
from typing import Any
|
||
|
||
from agentkit.core.plan_schema import (
|
||
ExecutionPlan,
|
||
PlanStep,
|
||
PlanStepStatus,
|
||
SkillGap,
|
||
SkillGapLevel,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class GoalPlanner:
|
||
"""目标分析与计划生成器
|
||
|
||
将自然语言目标分解为结构化执行计划,包含任务拆解、
|
||
依赖关系和并行度识别。
|
||
|
||
使用方式:
|
||
planner = GoalPlanner()
|
||
plan = await planner.generate_plan(
|
||
goal="调研 3 个竞品 SEO 策略并生成对比报告",
|
||
context={},
|
||
available_skills=["web_search", "seo_analyzer", "report_generator"],
|
||
)
|
||
"""
|
||
|
||
def __init__(self, llm_gateway: Any = None, max_parallel: int = 5):
|
||
"""
|
||
Args:
|
||
llm_gateway: LLM Gateway,用于细化计划(可选)
|
||
max_parallel: 最大并行步骤数
|
||
"""
|
||
self._llm_gateway = llm_gateway
|
||
self._max_parallel = max_parallel
|
||
|
||
async def generate_plan(
|
||
self,
|
||
goal: str,
|
||
context: dict[str, Any] | None = None,
|
||
available_skills: list[str] | None = None,
|
||
) -> ExecutionPlan:
|
||
"""生成结构化执行计划
|
||
|
||
Args:
|
||
goal: 自然语言目标
|
||
context: 上下文信息(如已有数据、约束条件等)
|
||
available_skills: 可用 Skill 列表
|
||
|
||
Returns:
|
||
ExecutionPlan: 结构化执行计划
|
||
"""
|
||
context = context or {}
|
||
available_skills = available_skills or []
|
||
|
||
# 1. 通过规则/模板生成初始方案
|
||
plan = self._rule_based_decompose(goal, context, available_skills)
|
||
|
||
# 2. 识别能力缺口
|
||
plan.skill_gaps = self._identify_skill_gaps(plan, available_skills)
|
||
|
||
# 3. 如果有 LLM Gateway 且初始方案不够精确,让 LLM 细化
|
||
if self._llm_gateway and self._should_refine_with_llm(plan):
|
||
plan = await self._llm_refine_plan(goal, plan, context, available_skills)
|
||
# 细化后重新识别能力缺口
|
||
plan.skill_gaps = self._identify_skill_gaps(plan, available_skills)
|
||
|
||
# 4. 构建并行组
|
||
plan.parallel_groups = self._build_parallel_groups(plan.steps)
|
||
|
||
return plan
|
||
|
||
def _rule_based_decompose(
|
||
self,
|
||
goal: str,
|
||
context: dict[str, Any],
|
||
available_skills: list[str],
|
||
) -> ExecutionPlan:
|
||
"""基于规则/模板的目标分解
|
||
|
||
使用启发式规则识别目标中的并列结构和顺序依赖,
|
||
生成初始执行计划。
|
||
"""
|
||
steps: list[PlanStep] = []
|
||
|
||
# 识别并列结构:如"3 个竞品"、"3个方案"、"A、B、C"
|
||
parallel_items = self._extract_parallel_items(goal)
|
||
|
||
if parallel_items and len(parallel_items) > 1:
|
||
# 有并列结构:每个并列项生成一个并行步骤 + 汇总步骤
|
||
steps = self._decompose_parallel_goal(goal, parallel_items, available_skills)
|
||
else:
|
||
# 无明显并列结构:尝试识别顺序步骤
|
||
sequential_parts = self._extract_sequential_parts(goal)
|
||
if len(sequential_parts) > 1:
|
||
steps = self._decompose_sequential_goal(goal, sequential_parts, available_skills)
|
||
else:
|
||
# 单步任务
|
||
steps = self._decompose_simple_goal(goal, available_skills)
|
||
|
||
return ExecutionPlan(
|
||
goal=goal,
|
||
steps=steps,
|
||
)
|
||
|
||
def _extract_parallel_items(self, goal: str) -> list[str]:
|
||
"""从目标中提取并列项
|
||
|
||
识别模式:
|
||
- "N 个 X":如"3 个竞品"、"5 个方案"
|
||
- "A、B、C":顿号分隔的并列项
|
||
- "A, B, C":逗号分隔的并列项
|
||
"""
|
||
items: list[str] = []
|
||
|
||
# 模式1:"N 个 X" — 识别数量+类别
|
||
count_match = re.search(r"(\d+)\s*个\s*(.+?)(?:的|和|并|以及|,|,|$)", goal)
|
||
if count_match:
|
||
count = int(count_match.group(1))
|
||
category = count_match.group(2).strip()
|
||
# 生成 N 个并列项
|
||
for i in range(1, count + 1):
|
||
items.append(f"{category} {i}")
|
||
return items
|
||
|
||
# 模式2:顿号分隔 — "竞品A、竞品B、竞品C"
|
||
if "、" in goal:
|
||
# 提取顿号分隔的片段
|
||
parts = re.split(r"[、]", goal)
|
||
# 过滤掉太短的片段(可能是标点噪声)
|
||
meaningful = [p.strip() for p in parts if len(p.strip()) > 1]
|
||
if len(meaningful) >= 2:
|
||
items = meaningful
|
||
return items
|
||
|
||
# 模式3:英文逗号分隔 — "A, B, C"
|
||
if "," in goal:
|
||
parts = goal.split(",")
|
||
meaningful = [p.strip() for p in parts if len(p.strip()) > 1]
|
||
if len(meaningful) >= 2:
|
||
items = meaningful
|
||
return items
|
||
|
||
return items
|
||
|
||
def _extract_sequential_parts(self, goal: str) -> list[str]:
|
||
"""从目标中提取顺序步骤
|
||
|
||
识别模式:
|
||
- "并":如"调研并生成报告"
|
||
- "然后"/"接着"/"再":顺序连接词
|
||
- "→"/"->":箭头分隔
|
||
"""
|
||
parts: list[str] = []
|
||
|
||
# 模式1:箭头分隔
|
||
if "→" in goal or "->" in goal:
|
||
separator = "→" if "→" in goal else "->"
|
||
parts = [p.strip() for p in goal.split(separator) if p.strip()]
|
||
return parts
|
||
|
||
# 模式2:顺序连接词
|
||
sequential_patterns = [
|
||
r"(.+?)然后(.+)",
|
||
r"(.+?)接着(.+)",
|
||
r"(.+?)之后再(.+)",
|
||
]
|
||
for pattern in sequential_patterns:
|
||
match = re.search(pattern, goal)
|
||
if match:
|
||
parts = [g.strip() for g in match.groups() if g.strip()]
|
||
return parts
|
||
|
||
# 模式3:"并" 连接 — 如"调研并生成报告"
|
||
if "并" in goal:
|
||
match = re.search(r"(.+?)并(.+)", goal)
|
||
if match:
|
||
parts = [g.strip() for g in match.groups() if g.strip()]
|
||
return parts
|
||
|
||
return parts
|
||
|
||
def _decompose_parallel_goal(
|
||
self,
|
||
goal: str,
|
||
parallel_items: list[str],
|
||
available_skills: list[str],
|
||
) -> list[PlanStep]:
|
||
"""分解包含并列结构的目标
|
||
|
||
生成 N 个并行步骤 + 1 个汇总步骤。
|
||
"""
|
||
steps: list[PlanStep] = []
|
||
parallel_step_ids: list[str] = []
|
||
|
||
# 为每个并列项生成一个并行步骤
|
||
for i, item in enumerate(parallel_items):
|
||
step_id = f"step-{i}"
|
||
required_skills = self._infer_required_skills(item, available_skills)
|
||
steps.append(PlanStep(
|
||
step_id=step_id,
|
||
name=f"处理: {item}",
|
||
description=f"对「{item}」执行相关操作",
|
||
dependencies=[],
|
||
parallel_group=0,
|
||
required_skills=required_skills,
|
||
))
|
||
parallel_step_ids.append(step_id)
|
||
|
||
# 汇总步骤:依赖所有并行步骤
|
||
summary_skills = self._infer_required_skills("汇总 生成 报告", available_skills)
|
||
steps.append(PlanStep(
|
||
step_id=f"step-{len(parallel_items)}",
|
||
name="汇总结果",
|
||
description="汇总所有并行步骤的结果,生成最终输出",
|
||
dependencies=parallel_step_ids,
|
||
parallel_group=1,
|
||
required_skills=summary_skills,
|
||
))
|
||
|
||
return steps
|
||
|
||
def _decompose_sequential_goal(
|
||
self,
|
||
goal: str,
|
||
sequential_parts: list[str],
|
||
available_skills: list[str],
|
||
) -> list[PlanStep]:
|
||
"""分解包含顺序步骤的目标"""
|
||
steps: list[PlanStep] = []
|
||
|
||
for i, part in enumerate(sequential_parts):
|
||
step_id = f"step-{i}"
|
||
dependencies = [f"step-{i - 1}"] if i > 0 else []
|
||
required_skills = self._infer_required_skills(part, available_skills)
|
||
steps.append(PlanStep(
|
||
step_id=step_id,
|
||
name=part[:50], # 截取前 50 字符作为名称
|
||
description=part,
|
||
dependencies=dependencies,
|
||
parallel_group=i,
|
||
required_skills=required_skills,
|
||
))
|
||
|
||
return steps
|
||
|
||
def _decompose_simple_goal(
|
||
self,
|
||
goal: str,
|
||
available_skills: list[str],
|
||
) -> list[PlanStep]:
|
||
"""分解简单目标为单步计划"""
|
||
required_skills = self._infer_required_skills(goal, available_skills)
|
||
return [
|
||
PlanStep(
|
||
step_id="step-0",
|
||
name=goal[:50],
|
||
description=goal,
|
||
dependencies=[],
|
||
parallel_group=0,
|
||
required_skills=required_skills,
|
||
)
|
||
]
|
||
|
||
def _infer_required_skills(self, text: str, available_skills: list[str]) -> list[str]:
|
||
"""根据文本推断所需的 Skill
|
||
|
||
基于关键词匹配,将文本中的意图映射到可用 Skill。
|
||
"""
|
||
skill_keywords: dict[str, list[str]] = {
|
||
"web_search": ["搜索", "查询", "查找", "调研", "search", "find", "lookup"],
|
||
"seo_analyzer": ["seo", "搜索引擎优化", "关键词", "排名"],
|
||
"report_generator": ["报告", "汇总", "总结", "生成", "对比", "report", "summary"],
|
||
"data_analyzer": ["分析", "统计", "数据", "analyze", "data"],
|
||
"document_writer": ["写", "撰写", "文档", "write", "document"],
|
||
"code_generator": ["代码", "编程", "开发", "code", "develop"],
|
||
}
|
||
|
||
text_lower = text.lower()
|
||
matched: list[str] = []
|
||
|
||
for skill, keywords in skill_keywords.items():
|
||
if skill not in available_skills:
|
||
continue
|
||
if any(kw in text_lower for kw in keywords):
|
||
matched.append(skill)
|
||
|
||
return matched
|
||
|
||
def _identify_skill_gaps(
|
||
self, plan: ExecutionPlan, available_skills: list[str]
|
||
) -> list[SkillGap]:
|
||
"""识别能力缺口
|
||
|
||
检查每个步骤所需的 Skill 是否可用,标注缺口。
|
||
"""
|
||
gaps: list[SkillGap] = []
|
||
available_set = set(available_skills)
|
||
|
||
for step in plan.steps:
|
||
for skill in step.required_skills:
|
||
if skill not in available_set:
|
||
gaps.append(SkillGap(
|
||
step_name=step.name,
|
||
required_skill=skill,
|
||
level=SkillGapLevel.HIGH,
|
||
suggestion=f"请安装或注册 '{skill}' Skill,或手动完成该步骤",
|
||
))
|
||
|
||
# 如果步骤没有匹配到任何 Skill,标注缺口
|
||
if not step.required_skills:
|
||
if not available_skills:
|
||
# 无可用 Skill 时标注为 HIGH
|
||
gaps.append(SkillGap(
|
||
step_name=step.name,
|
||
required_skill="(无可用 Skill)",
|
||
level=SkillGapLevel.HIGH,
|
||
suggestion="当前无可用 Skill,请注册所需 Skill 或手动完成该步骤",
|
||
))
|
||
else:
|
||
# 有 Skill 但未匹配到时标注为 MEDIUM
|
||
gaps.append(SkillGap(
|
||
step_name=step.name,
|
||
required_skill="(未匹配)",
|
||
level=SkillGapLevel.MEDIUM,
|
||
suggestion=f"无法自动匹配 Skill,可用 Skill: {', '.join(available_skills[:5])}",
|
||
))
|
||
|
||
return gaps
|
||
|
||
def _should_refine_with_llm(self, plan: ExecutionPlan) -> bool:
|
||
"""判断是否需要 LLM 细化
|
||
|
||
当初始方案步骤描述过于简单、能力缺口较多、或所有步骤
|
||
都没有匹配到 Skill 时,需要 LLM 细化。
|
||
"""
|
||
# 如果所有步骤都没有匹配到任何 Skill,让 LLM 重新评估
|
||
if plan.steps and all(not s.required_skills for s in plan.steps):
|
||
return True
|
||
|
||
# 如果有较多能力缺口,让 LLM 重新评估
|
||
if len(plan.skill_gaps) > len(plan.steps):
|
||
return True
|
||
|
||
return False
|
||
|
||
async def _llm_refine_plan(
|
||
self,
|
||
goal: str,
|
||
initial_plan: ExecutionPlan,
|
||
context: dict[str, Any],
|
||
available_skills: list[str],
|
||
) -> ExecutionPlan:
|
||
"""使用 LLM 细化执行计划
|
||
|
||
将初始方案作为上下文注入 LLM prompt,让 LLM 细化调整。
|
||
"""
|
||
import json
|
||
|
||
# 构建初始方案摘要
|
||
initial_summary = json.dumps(
|
||
[s.to_dict() for s in initial_plan.steps],
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
)
|
||
|
||
skills_str = ", ".join(available_skills) if available_skills else "无"
|
||
|
||
prompt = (
|
||
f"Refine the following execution plan for the given goal.\n\n"
|
||
f"Goal: {goal}\n\n"
|
||
f"Initial Plan (generated by rules):\n{initial_summary}\n\n"
|
||
f"Available Skills: {skills_str}\n\n"
|
||
f"Context: {json.dumps(context, ensure_ascii=False) if context else 'None'}\n\n"
|
||
'Respond ONLY with a JSON array of steps: '
|
||
'[{"name": "...", "description": "...", "dependencies": [], '
|
||
'"required_skills": [...]}]\n'
|
||
"The dependencies field lists step indices (0-based) that must complete first.\n"
|
||
"Each step should have a clear, specific description (at least 20 characters).\n"
|
||
"Do not include any other text."
|
||
)
|
||
|
||
try:
|
||
response = await self._llm_gateway.chat(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
model="default",
|
||
)
|
||
|
||
step_defs = json.loads(response.content)
|
||
if not isinstance(step_defs, list) or not step_defs:
|
||
return initial_plan
|
||
|
||
steps: list[PlanStep] = []
|
||
for i, defn in enumerate(step_defs):
|
||
depends_on = [f"step-{j}" for j in defn.get("dependencies", [])]
|
||
steps.append(PlanStep(
|
||
step_id=f"step-{i}",
|
||
name=defn.get("name", f"Step {i}"),
|
||
description=defn.get("description", ""),
|
||
dependencies=depends_on,
|
||
parallel_group=0, # 后续由 _build_parallel_groups 重新计算
|
||
required_skills=defn.get("required_skills", []),
|
||
))
|
||
|
||
return ExecutionPlan(
|
||
goal=goal,
|
||
steps=steps,
|
||
metadata={"refined_by_llm": True},
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.warning(f"LLM plan refinement failed, using initial plan: {e}")
|
||
return initial_plan
|
||
|
||
def _build_parallel_groups(self, steps: list[PlanStep]) -> list[list[str]]:
|
||
"""构建并行执行组
|
||
|
||
基于依赖关系拓扑排序,无依赖的步骤分到同一组并行执行。
|
||
复用 Orchestrator._build_parallel_groups() 的拓扑排序逻辑。
|
||
"""
|
||
step_map = {s.step_id: s for s in steps}
|
||
completed: set[str] = set()
|
||
groups: list[list[str]] = []
|
||
remaining = set(s.step_id for s in steps)
|
||
|
||
while remaining:
|
||
# 找到所有依赖已满足的步骤
|
||
ready = []
|
||
for sid in remaining:
|
||
step = step_map[sid]
|
||
if all(dep in completed for dep in step.dependencies):
|
||
ready.append(sid)
|
||
|
||
if not ready:
|
||
# 循环依赖 — 将剩余步骤放入一组
|
||
groups.append(list(remaining))
|
||
break
|
||
|
||
# 限制组大小
|
||
group = ready[: self._max_parallel]
|
||
groups.append(group)
|
||
for sid in group:
|
||
completed.add(sid)
|
||
remaining.discard(sid)
|
||
|
||
# 更新步骤的 parallel_group 字段
|
||
for group_idx, group in enumerate(groups):
|
||
for sid in group:
|
||
step = step_map.get(sid)
|
||
if step:
|
||
step.parallel_group = group_idx
|
||
|
||
return groups
|
||
|
||
def update_plan_from_feedback(
|
||
self,
|
||
plan: ExecutionPlan,
|
||
modifications: dict[str, Any],
|
||
) -> ExecutionPlan:
|
||
"""根据用户反馈更新计划
|
||
|
||
Args:
|
||
plan: 原始执行计划
|
||
modifications: 修改内容,可包含:
|
||
- add_steps: 新增步骤列表
|
||
- remove_steps: 要移除的步骤 ID 列表
|
||
- update_steps: 要更新的步骤 {step_id: {field: value}}
|
||
- reorder: 是否重新排序
|
||
|
||
Returns:
|
||
更新后的 ExecutionPlan
|
||
"""
|
||
steps = list(plan.steps)
|
||
|
||
# 移除步骤
|
||
remove_ids = set(modifications.get("remove_steps", []))
|
||
if remove_ids:
|
||
steps = [s for s in steps if s.step_id not in remove_ids]
|
||
# 清理依赖引用
|
||
for step in steps:
|
||
step.dependencies = [d for d in step.dependencies if d not in remove_ids]
|
||
|
||
# 更新步骤
|
||
update_map: dict[str, dict] = modifications.get("update_steps", {})
|
||
for step in steps:
|
||
if step.step_id in update_map:
|
||
updates = update_map[step.step_id]
|
||
for field_name, value in updates.items():
|
||
if hasattr(step, field_name):
|
||
setattr(step, field_name, value)
|
||
|
||
# 新增步骤
|
||
add_steps = modifications.get("add_steps", [])
|
||
for new_step_def in add_steps:
|
||
step_id = new_step_def.get("step_id", f"step-{len(steps)}")
|
||
# 确保唯一性
|
||
existing_ids = {s.step_id for s in steps}
|
||
while step_id in existing_ids:
|
||
step_id = f"step-{uuid.uuid4().hex[:4]}"
|
||
|
||
steps.append(PlanStep(
|
||
step_id=step_id,
|
||
name=new_step_def.get("name", "New Step"),
|
||
description=new_step_def.get("description", ""),
|
||
dependencies=new_step_def.get("dependencies", []),
|
||
required_skills=new_step_def.get("required_skills", []),
|
||
))
|
||
|
||
# 重新构建并行组
|
||
parallel_groups = self._build_parallel_groups(steps)
|
||
|
||
return ExecutionPlan(
|
||
plan_id=plan.plan_id,
|
||
goal=plan.goal,
|
||
steps=steps,
|
||
parallel_groups=parallel_groups,
|
||
skill_gaps=plan.skill_gaps, # 保留原有缺口信息
|
||
confirmed=False, # 修改后需要重新确认
|
||
metadata=plan.metadata,
|
||
)
|
||
|
||
def validate_plan(self, plan: ExecutionPlan) -> list[str]:
|
||
"""验证执行计划的合法性
|
||
|
||
Returns:
|
||
错误信息列表,空列表表示验证通过
|
||
"""
|
||
errors: list[str] = []
|
||
step_ids = {s.step_id for s in plan.steps}
|
||
|
||
# 检查依赖引用是否存在
|
||
for step in plan.steps:
|
||
for dep in step.dependencies:
|
||
if dep not in step_ids:
|
||
errors.append(f"步骤 '{step.step_id}' 依赖不存在的步骤 '{dep}'")
|
||
|
||
# 检查循环依赖
|
||
visited: set[str] = set()
|
||
in_stack: set[str] = set()
|
||
|
||
def has_cycle(sid: str) -> bool:
|
||
if sid in in_stack:
|
||
return True
|
||
if sid in visited:
|
||
return False
|
||
visited.add(sid)
|
||
in_stack.add(sid)
|
||
step = plan.get_step(sid)
|
||
if step:
|
||
for dep in step.dependencies:
|
||
if has_cycle(dep):
|
||
return True
|
||
in_stack.discard(sid)
|
||
return False
|
||
|
||
for step in plan.steps:
|
||
if has_cycle(step.step_id):
|
||
errors.append(f"检测到循环依赖,涉及步骤 '{step.step_id}'")
|
||
break
|
||
|
||
# 检查并行组与步骤的一致性
|
||
grouped_ids: set[str] = set()
|
||
for group in plan.parallel_groups:
|
||
for sid in group:
|
||
if sid not in step_ids:
|
||
errors.append(f"并行组包含不存在的步骤 '{sid}'")
|
||
if sid in grouped_ids:
|
||
errors.append(f"步骤 '{sid}' 出现在多个并行组中")
|
||
grouped_ids.add(sid)
|
||
|
||
ungrouped = step_ids - grouped_ids
|
||
if ungrouped:
|
||
errors.append(f"步骤未分配到并行组: {', '.join(ungrouped)}")
|
||
|
||
return errors
|