@@ -36,6 +36,13 @@ class HubAction(str, Enum):
3636 NONE = "none" # 无额外操作,按原逻辑走
3737
3838
39+ class FaultType (str , Enum ):
40+ """失败归因:谁的锅?"""
41+ APP = "app" # 被测应用的Bug(数据错误、页面崩溃、功能异常)→ 报给编程AI
42+ TEST = "test" # 测试脚本/框架问题(选择器错、时序问题、弹窗遮挡)→ 中枢自己处理
43+ UNKNOWN = "unknown" # 无法判断
44+
45+
3946@dataclass
4047class StepRecord :
4148 """单步操作记录,用于构建历史上下文。"""
@@ -88,6 +95,7 @@ class HubDecision:
8895 """AI中枢返回的决策。"""
8996 action : HubAction = HubAction .NONE
9097 reason : str = ""
98+ fault : FaultType = FaultType .UNKNOWN # 失败归因
9199 popup_closed : bool = False # 是否关闭了弹窗
92100 ai_cost_ms : float = 0 # 本次决策的AI调用耗时
93101
@@ -141,6 +149,7 @@ def __init__(self, ai_client: Optional[Any] = None) -> None:
141149 self ._total_heals : int = 0
142150 self ._total_l1_calls : int = 0
143151 self ._total_l2_calls : int = 0
152+ self ._total_rule_judgments : int = 0
144153 self ._total_skipped_scenes : int = 0
145154
146155 # ── 公共接口 ──────────────────────────────────────────
@@ -186,6 +195,11 @@ async def on_step_failed(self, ctx: StepContext) -> HubDecision:
186195 reason = f"连续失败{ self ._consecutive_failures } 步,场景级熔断" ,
187196 )
188197
198+ # ── L0.5 规则预判:不需要AI就能确定是APP Bug的场景 ──
199+ rule_decision = self ._try_rule_judge (ctx )
200+ if rule_decision :
201+ return rule_decision
202+
189203 # ── L1 弹窗自愈(仅对可能被弹窗遮挡的操作)──
190204 if ctx .action in ("click" , "fill" , "assert_text" , "assert_visible" ):
191205 decision = await self ._try_l1_popup_heal (ctx )
@@ -208,9 +222,48 @@ def stats(self) -> dict:
208222 "total_heals" : self ._total_heals ,
209223 "l1_calls" : self ._total_l1_calls ,
210224 "l2_calls" : self ._total_l2_calls ,
225+ "rule_judgments" : self ._total_rule_judgments ,
211226 "skipped_scenes" : self ._total_skipped_scenes ,
212227 }
213228
229+ # ── L0.5: 规则预判(零AI成本)─────────────────────────
230+
231+ def _try_rule_judge (self , ctx : StepContext ) -> Optional [HubDecision ]:
232+ """L0.5:基于错误信息的规则预判,不调AI,零token消耗。
233+
234+ 能100%确定是APP Bug的情况:
235+ - assert_text:元素找到了,但文字不对 → 应用显示了错误数据
236+ - 计算验证失败:数值算错了 → 应用计算逻辑有Bug
237+ 这些不需要截图也不需要AI分析,直接判定。
238+
239+ 不能确定的情况(交给L1/L2):
240+ - 元素找不到(可能是选择器错,也可能是应用没渲染)
241+ - 通用异常(需要AI看截图)
242+ """
243+ err = ctx .error_message or ""
244+
245+ # assert_text:找到元素但文字不对 → 100% APP Bug
246+ if ctx .action == "assert_text" and "文本断言失败" in err :
247+ self ._total_rule_judgments += 1
248+ logger .info (" 📋 AI中枢规则预判: 文本不匹配 → APP Bug(省去AI调用)" )
249+ return HubDecision (
250+ action = HubAction .NONE ,
251+ fault = FaultType .APP ,
252+ reason = f"规则预判: { err [:120 ]} " ,
253+ )
254+
255+ # 计算验证失败 → 100% APP Bug
256+ if ctx .action == "assert_text" and "计算" in err :
257+ self ._total_rule_judgments += 1
258+ logger .info (" 📋 AI中枢规则预判: 计算错误 → APP Bug(省去AI调用)" )
259+ return HubDecision (
260+ action = HubAction .NONE ,
261+ fault = FaultType .APP ,
262+ reason = f"规则预判: { err [:120 ]} " ,
263+ )
264+
265+ return None
266+
214267 # ── L1: 弹窗自愈 ─────────────────────────────────────
215268
216269 async def _try_l1_popup_heal (self , ctx : StepContext ) -> HubDecision :
@@ -336,13 +389,16 @@ async def _try_l2_diagnose(self, ctx: StepContext) -> HubDecision:
336389 f"但是失败了。报错:{ (ctx .error_message or '未知' )[:200 ]} \n \n "
337390 "现在请你看看这张截图,就像你自己坐在电脑前一样,告诉我:\n "
338391 "- 画面上是什么情况?\n "
339- "- 为什么这一步做不了?(比如:元素没出来?被遮住了?页面还没加载好?跳转到了别的页面?)\n "
340- "- 你觉得应该怎么办?\n \n "
392+ "- 为什么这一步做不了?\n "
393+ "- 这个失败是【应用自己的Bug】还是【测试脚本的问题】?\n \n "
394+ "判断标准:\n "
395+ "- app = 应用的Bug:页面显示了错误的数据、功能不正常、页面崩溃/白屏、\n "
396+ " 计算结果不对、该出现的内容缺失(应用自身没实现好)\n "
397+ "- test = 测试脚本的问题:选择器找不到元素(但页面看起来正常)、\n "
398+ " 页面还在加载、弹窗遮挡、动画没播完、时序太快\n \n "
341399 "返回JSON:\n "
342- '{"diagnosis": "一句话说清楚原因", "suggestion": "retry"或"skip"或"none"}\n '
343- "retry = 有可能再试一次就好了(页面在加载、动画还没完)\n "
344- "skip = 这步确实没法做(元素不存在、页面跳错了)\n "
345- "none = 看不出来、不确定\n \n "
400+ '{"diagnosis": "一句话说清楚原因", "fault": "app"或"test"或"unknown", "suggestion": "retry"或"skip"或"none"}\n '
401+ "suggestion含义:retry=再试一次可能好/skip=跳过/none=不确定\n \n "
346402 "只返回JSON。"
347403 )
348404
@@ -361,22 +417,35 @@ async def _try_l2_diagnose(self, ctx: StepContext) -> HubDecision:
361417 if not json_match :
362418 return HubDecision (action = HubAction .NONE , reason = "L2返回无JSON" , ai_cost_ms = cost_ms )
363419
364- result = json .loads (json_match .group ().replace ("'" , '"' ))
420+ # 容错:去掉中文引号、修复常见JSON格式问题
421+ raw_json = json_match .group ()
422+ raw_json = raw_json .replace ("\u201c " , '"' ).replace ("\u201d " , '"' ) # 中文引号
423+ raw_json = raw_json .replace ("'" , '"' )
424+ result = json .loads (raw_json )
365425 diagnosis = result .get ("diagnosis" , "" )
366426 suggestion = result .get ("suggestion" , "none" )
427+ fault_str = result .get ("fault" , "unknown" )
428+
429+ # 解析归因
430+ try :
431+ fault = FaultType (fault_str )
432+ except ValueError :
433+ fault = FaultType .UNKNOWN
367434
368- logger .info (" 🧠 AI中枢L2诊断: {} → {} " , diagnosis , suggestion )
435+ logger .info (" 🧠 AI中枢L2诊断: {} | 归因={} | 建议={} " , diagnosis , fault . value , suggestion )
369436
370437 if suggestion == "retry" :
371438 return HubDecision (
372439 action = HubAction .RETRY ,
373440 reason = f"L2诊断建议重试: { diagnosis } " ,
441+ fault = fault ,
374442 ai_cost_ms = cost_ms ,
375443 )
376444 elif suggestion == "skip" :
377445 return HubDecision (
378446 action = HubAction .SKIP_STEP ,
379447 reason = f"L2诊断建议跳过: { diagnosis } " ,
448+ fault = fault ,
380449 ai_cost_ms = cost_ms ,
381450 )
382451
0 commit comments