fischer-agentkit/src/agentkit/evolution/pitfall_detector.py

391 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""PitfallDetector - 任务避坑预警
新任务启动时检索历史失败经验,匹配当前计划步骤,自动预警。
基于 ExperienceStore 中存储的失败经验,将失败步骤与当前计划步骤
进行关键词匹配,计算失败率并按严重程度返回预警列表。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Protocol
logger = logging.getLogger(__name__)
class WarningLevel(str, Enum):
"""预警级别"""
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
@dataclass
class PitfallWarning:
"""避坑预警
Attributes:
step_name: 计划步骤名称
warning_level: 预警级别HIGH/MEDIUM/LOW
failure_rate: 历史失败率0.0 ~ 1.0
historical_failures: 历史失败原因列表
suggestion: 优化建议
"""
step_name: str
warning_level: WarningLevel
failure_rate: float
historical_failures: list[str] = field(default_factory=list)
suggestion: str = ""
class ExperienceStoreProtocol(Protocol):
"""ExperienceStore 协议接口,用于类型标注"""
async def search(
self,
query: str,
top_k: int = 5,
task_type: str | None = None,
search_multiplier: int = 5,
) -> list[Any]:
...
# 预警级别阈值
_HIGH_THRESHOLD = 0.5
_MEDIUM_THRESHOLD = 0.2
class PitfallDetector:
"""避坑检测器
新任务启动时检索历史失败经验,匹配当前计划步骤,自动预警。
使用方式:
detector = PitfallDetector(experience_store)
warnings = await detector.check_pitfalls(
task_type="code_review",
planned_steps=[plan_step1, plan_step2, ...],
)
匹配逻辑:
1. 检索同类任务的失败经验
2. 从失败经验中提取失败步骤
3. 将失败步骤与当前计划步骤进行关键词匹配
4. 计算失败率并分配预警级别
预警级别:
- HIGH: failure_rate >= 0.5(历史高失败率步骤)
- MEDIUM: failure_rate >= 0.2(有失败记录但频率低)
- LOW: 有任何失败记录
"""
def __init__(
self,
experience_store: ExperienceStoreProtocol,
similarity_threshold: float = 0.3,
max_search_results: int = 50,
):
"""
Args:
experience_store: 经验存储实例ExperienceStore 或 InMemoryExperienceStore
similarity_threshold: 步骤名称关键词匹配的最小相似度阈值
max_search_results: 从经验存储检索的最大结果数
"""
self._store = experience_store
self._similarity_threshold = similarity_threshold
self._max_search_results = max_search_results
async def check_pitfalls(
self,
task_type: str,
planned_steps: list[Any],
) -> list[PitfallWarning]:
"""检查计划步骤中的潜在陷阱
Args:
task_type: 任务类型
planned_steps: 计划步骤列表PlanStep 对象或具有 name/description 属性的对象)
Returns:
按严重程度排序的预警列表HIGH → MEDIUM → LOW
"""
if not planned_steps:
return []
# 1. 检索同类任务的所有经验(包含成功和失败,用于计算步骤级失败率)
all_experiences = await self._search_experiences(task_type)
if not all_experiences:
logger.debug(f"No experiences found for task_type={task_type}")
return []
# 2. 从经验中提取步骤级别的失败统计
step_failure_stats = self._extract_step_failure_stats(all_experiences)
# 3. 匹配当前计划步骤并生成预警
warnings = self._match_and_warn(planned_steps, step_failure_stats)
# 4. 按严重程度排序HIGH → MEDIUM → LOW同级别按失败率降序
warnings.sort(key=lambda w: (_warning_level_order(w.warning_level), -w.failure_rate))
if warnings:
logger.info(
f"PitfallDetector found {len(warnings)} warnings for task_type={task_type}: "
f"{sum(1 for w in warnings if w.warning_level == WarningLevel.HIGH)} HIGH, "
f"{sum(1 for w in warnings if w.warning_level == WarningLevel.MEDIUM)} MEDIUM, "
f"{sum(1 for w in warnings if w.warning_level == WarningLevel.LOW)} LOW"
)
return warnings
async def _search_experiences(self, task_type: str) -> list[Any]:
"""检索指定任务类型的所有经验(包含成功和失败)"""
try:
results = await self._store.search(
query=task_type,
top_k=self._max_search_results,
task_type=task_type,
)
return results
except (RuntimeError, ValueError, KeyError) as e:
logger.error(f"Failed to search experiences for pitfall detection: {e}")
return []
def _extract_step_failure_stats(
self, failed_experiences: list[Any]
) -> dict[str, _StepFailureStats]:
"""从失败经验中提取步骤级别的失败统计
steps_summary 可以是 str 或 list[dict]
- list[dict]: 每个字典包含 step_name, outcome, duration_seconds, error
- str: 退化为整体统计
Returns:
以步骤名称为 key 的失败统计字典
"""
stats: dict[str, _StepFailureStats] = {}
for exp in failed_experiences:
steps_summary = exp.steps_summary
# 如果 steps_summary 是字符串,无法提取步骤级信息
if isinstance(steps_summary, str):
continue
if not isinstance(steps_summary, list):
continue
for step in steps_summary:
if not isinstance(step, dict):
continue
step_name = step.get("step_name", "")
if not step_name:
continue
outcome = step.get("outcome", "")
error = step.get("error", "")
if step_name not in stats:
stats[step_name] = _StepFailureStats(
step_name=step_name,
total_occurrences=0,
failure_occurrences=0,
failure_reasons=[],
optimization_tips=[],
)
s = stats[step_name]
s.total_occurrences += 1
if outcome in ("failure", "failed", "error"):
s.failure_occurrences += 1
if error:
s.failure_reasons.append(error)
# 收集优化建议 — only add to steps that are part of this experience
if hasattr(exp, 'optimization_tips') and exp.optimization_tips:
experience_steps = set(exp.steps) if hasattr(exp, 'steps') and exp.steps else set()
for step_name, s in stats.items():
if experience_steps and step_name in experience_steps:
s.optimization_tips.extend(exp.optimization_tips)
return stats
def _match_and_warn(
self,
planned_steps: list[Any],
step_failure_stats: dict[str, _StepFailureStats],
) -> list[PitfallWarning]:
"""将计划步骤与失败统计进行匹配,生成预警"""
warnings: list[PitfallWarning] = []
for step in planned_steps:
step_name = getattr(step, "name", "")
step_description = getattr(step, "description", "")
if not step_name:
continue
# 查找最佳匹配的失败步骤
best_match: _StepFailureStats | None = None
best_similarity = 0.0
for stats_step_name, stats in step_failure_stats.items():
similarity = _compute_name_similarity(
step_name, step_description, stats_step_name
)
if similarity > best_similarity:
best_similarity = similarity
best_match = stats
# 相似度低于阈值,跳过
if best_match is None or best_similarity < self._similarity_threshold:
continue
# 计算失败率
failure_rate = (
best_match.failure_occurrences / best_match.total_occurrences
if best_match.total_occurrences > 0
else 0.0
)
# 分配预警级别
warning_level = _determine_warning_level(failure_rate)
# 生成建议
suggestion = _build_suggestion(best_match, failure_rate)
warning = PitfallWarning(
step_name=step_name,
warning_level=warning_level,
failure_rate=round(failure_rate, 4),
historical_failures=best_match.failure_reasons[:5], # 最多保留 5 条
suggestion=suggestion,
)
warnings.append(warning)
return warnings
# ── 内部辅助类 ──────────────────────────────────────────────
@dataclass
class _StepFailureStats:
"""步骤级别的失败统计(内部使用)"""
step_name: str
total_occurrences: int
failure_occurrences: int
failure_reasons: list[str]
optimization_tips: list[str]
# ── 辅助函数 ──────────────────────────────────────────────
def _compute_name_similarity(
step_name: str, step_description: str, historical_step_name: str
) -> float:
"""计算步骤名称的关键词重叠相似度
基于关键词集合的 Jaccard 相似度,同时考虑 step_name 和 step_description。
Args:
step_name: 当前计划步骤名称
step_description: 当前计划步骤描述
historical_step_name: 历史步骤名称
Returns:
相似度分数0.0 ~ 1.0
"""
# 提取关键词:将名称拆分为词,过滤掉常见停用词
current_keywords = _extract_keywords(f"{step_name} {step_description}")
historical_keywords = _extract_keywords(historical_step_name)
if not current_keywords or not historical_keywords:
return 0.0
# Jaccard 相似度
intersection = current_keywords & historical_keywords
union = current_keywords | historical_keywords
if not union:
return 0.0
return len(intersection) / len(union)
_STOP_WORDS = frozenset({
"a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
"of", "with", "by", "from", "is", "are", "was", "were", "be", "been",
"being", "have", "has", "had", "do", "does", "did", "will", "would",
"could", "should", "may", "might", "can", "shall", "not", "no",
})
def _extract_keywords(text: str) -> frozenset[str]:
"""从文本中提取关键词集合
转小写、按空白/下划线/连字符拆分、过滤停用词和单字符词。
"""
# 统一分隔符
normalized = text.lower().replace("_", " ").replace("-", " ")
words = normalized.split()
return frozenset(
w for w in words
if len(w) > 1 and w not in _STOP_WORDS
)
def _determine_warning_level(failure_rate: float) -> WarningLevel:
"""根据失败率确定预警级别
- HIGH: failure_rate >= 0.5
- MEDIUM: failure_rate >= 0.2
- LOW: 有任何失败记录
"""
if failure_rate >= _HIGH_THRESHOLD:
return WarningLevel.HIGH
if failure_rate >= _MEDIUM_THRESHOLD:
return WarningLevel.MEDIUM
return WarningLevel.LOW
def _warning_level_order(level: WarningLevel) -> int:
"""预警级别排序值(越小越严重)"""
return {
WarningLevel.HIGH: 0,
WarningLevel.MEDIUM: 1,
WarningLevel.LOW: 2,
}[level]
def _build_suggestion(stats: _StepFailureStats, failure_rate: float) -> str:
"""根据失败统计生成优化建议"""
parts: list[str] = []
if failure_rate >= _HIGH_THRESHOLD:
parts.append(f"该步骤历史失败率高达 {failure_rate:.0%},建议重点关注")
elif failure_rate >= _MEDIUM_THRESHOLD:
parts.append(f"该步骤历史失败率为 {failure_rate:.0%},需注意风险")
else:
parts.append(f"该步骤有少量失败记录(失败率 {failure_rate:.0%}")
if stats.failure_reasons:
unique_reasons = list(dict.fromkeys(stats.failure_reasons))[:3]
reasons_str = "".join(unique_reasons)
parts.append(f"常见失败原因:{reasons_str}")
if stats.optimization_tips:
unique_tips = list(dict.fromkeys(stats.optimization_tips))[:2]
tips_str = "".join(unique_tips)
parts.append(f"建议:{tips_str}")
return "".join(parts)