Skip to content

Commit 6f634e1

Browse files
committed
v12.2: AI中枢归因预判 - 规则判定APP Bug零token + FaultType标签
1 parent bea4c10 commit 6f634e1

4 files changed

Lines changed: 117 additions & 13 deletions

File tree

src/testing/ai_hub.py

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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
4047
class 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

src/testing/blueprint_runner.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from src.testing.smart_input import generate_smart_value, is_auto_value
2727
from src.testing.log_slicer import LogSlicer
2828
from src.testing.smart_repair import RepairStrategy, SmartRepairDecider
29-
from src.testing.ai_hub import AIHub, HubAction, StepContext
29+
from src.testing.ai_hub import AIHub, FaultType, HubAction, StepContext
3030

3131

3232
class BlueprintRunner:
@@ -234,8 +234,13 @@ async def run(
234234
))
235235
all_results.append(result)
236236
if bug:
237+
self._label_bug_fault(bug, decision.fault)
237238
all_bugs.append(bug)
238239
break
240+
241+
# 归因标记
242+
if bug:
243+
self._label_bug_fault(bug, decision.fault)
239244
else:
240245
self._hub.on_step_passed()
241246

@@ -338,7 +343,13 @@ async def run(
338343
return report
339344

340345
# ── AI中枢桥接方法(供 AIHub 回调) ─────────────────
341-
346+
@staticmethod
347+
def _label_bug_fault(bug: BugReport, fault: FaultType) -> None:
348+
"""根据归因给Bug打标签,让编程AI知道谁的锅。"""
349+
if fault == FaultType.APP and not bug.category.startswith("["):
350+
bug.category = f"[应用Bug]{bug.category}"
351+
elif fault == FaultType.TEST and not bug.category.startswith("["):
352+
bug.category = f"[测试问题]{bug.category}"
342353
async def _hub_screenshot(self, tag: str):
343354
"""AIHub 截图回调。"""
344355
try:

src/testing/desktop_blueprint_runner.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
TestReport,
4141
)
4242
from src.testing.controller import TestController
43-
from src.testing.ai_hub import AIHub, HubAction, HubDecision, StepContext, DIALOG_BUTTON_KEYWORDS
43+
from src.testing.ai_hub import AIHub, FaultType, HubAction, HubDecision, StepContext, DIALOG_BUTTON_KEYWORDS
4444

4545
try:
4646
from src.core.ai_client import AIClient
@@ -237,8 +237,13 @@ async def run(self, blueprint: Blueprint) -> TestReport:
237237
))
238238
all_results.append(result)
239239
if bug:
240+
self._label_bug_fault(bug, decision.fault)
240241
all_bugs.append(bug)
241242
break
243+
244+
# 归因标记
245+
if bug:
246+
self._label_bug_fault(bug, decision.fault)
242247
else:
243248
self._hub.on_step_passed()
244249

@@ -441,7 +446,13 @@ async def _execute_step(
441446
# ── 弹窗自愈(第2层防御) ─────────────────────────────
442447

443448
# ── AI中枢桥接方法(供 AIHub 回调) ─────────────────
444-
449+
@staticmethod
450+
def _label_bug_fault(bug: BugReport, fault: FaultType) -> None:
451+
"""根据归因给Bug打标签,让编程AI知道谁的锅。"""
452+
if fault == FaultType.APP and not bug.category.startswith("["):
453+
bug.category = f"[应用Bug]{bug.category}"
454+
elif fault == FaultType.TEST and not bug.category.startswith("["):
455+
bug.category = f"[测试问题]{bug.category}"
445456
async def _hub_screenshot(self, tag: str):
446457
"""AIHub 截图回调:截图并返回路径。"""
447458
return await self._ctrl.screenshot(tag)

src/testing/mobile_blueprint_runner.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
)
3131
from src.testing.formula_validator import is_formula, validate_formula
3232
from src.testing.smart_input import generate_smart_value, is_auto_value
33-
from src.testing.ai_hub import AIHub, HubAction, StepContext, DIALOG_BUTTON_KEYWORDS
33+
from src.testing.ai_hub import AIHub, FaultType, HubAction, StepContext, DIALOG_BUTTON_KEYWORDS
3434

3535

3636
class MobileBlueprintRunner:
@@ -253,8 +253,13 @@ async def _adb_run(args, timeout=5):
253253
))
254254
all_results.append(result)
255255
if bug:
256+
self._label_bug_fault(bug, decision.fault)
256257
all_bugs.append(bug)
257258
break
259+
260+
# 归因标记
261+
if bug:
262+
self._label_bug_fault(bug, decision.fault)
258263
else:
259264
self._hub.on_step_passed()
260265

@@ -309,6 +314,14 @@ async def _adb_run(args, timeout=5):
309314

310315
# ── AI中枢桥接方法(供 AIHub 回调) ─────────────────
311316

317+
@staticmethod
318+
def _label_bug_fault(bug: BugReport, fault: FaultType) -> None:
319+
"""根据归因给Bug打标签,让编程AI知道谁的锅。"""
320+
if fault == FaultType.APP and not bug.category.startswith("["):
321+
bug.category = f"[应用Bug]{bug.category}"
322+
elif fault == FaultType.TEST and not bug.category.startswith("["):
323+
bug.category = f"[测试问题]{bug.category}"
324+
312325
async def _hub_screenshot(self, tag: str):
313326
"""AIHub 截图回调。"""
314327
try:

0 commit comments

Comments
 (0)