"""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)