fix(agent): 底部规格入口最右优先 (#209)

This commit is contained in:
QiuSW
2026-09-03 16:50:29 +08:00
parent fd013c634b
commit ac6a57e4f3
4 changed files with 91 additions and 6 deletions
@@ -337,7 +337,7 @@ object PddScreenParser {
val nestedSpecEntry = nestedSpecEntries.singleOrNull()
val bottomSpecEntries = if (panelOpen || explicitSpecEntry != null || nestedSpecEntry != null) emptyList() else
safeBottomSpecEntries(visibleNodes, visible, config)
val bottomSpecEntry = bottomSpecEntries.singleOrNull()
val bottomSpecEntry = bottomSpecEntries.firstOrNull()
val candidateSpecEntry = explicitSpecEntry ?: nestedSpecEntry?.anchor ?: bottomSpecEntry?.anchor
val candidateClickTarget = explicitSpecEntry ?: nestedSpecEntry?.clickTarget ?: bottomSpecEntry?.clickTarget
val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, candidateSpecEntry, config)
@@ -347,7 +347,8 @@ object PddScreenParser {
reviewPageOpen -> null
explicitSpecEntry != null -> "explicit_selection"
nestedSpecEntry != null -> "nested_selection"
bottomSpecEntry != null -> "bottom_purchase"
bottomSpecEntries.size == 1 -> "bottom_purchase"
bottomSpecEntry != null -> "bottom_purchase_rightmost"
else -> null
}
val quickConfirmationEntry = if (quickConfirmationEvidence) quickConfirmationSpecEntry(visibleNodes, visible, config) else null
@@ -539,7 +540,7 @@ object PddScreenParser {
val normalizedTarget = normalizedByPath[clickTarget.path] ?: return@mapNotNull null
SafeSpecEntry(normalizedAnchor, normalizedTarget) to clickTarget.bounds.width.toLong() * clickTarget.bounds.height
}
.sortedWith(compareBy<Pair<SafeSpecEntry, Long>> { it.second }.thenByDescending { it.first.clickTarget.bounds.centerX })
.sortedWith(compareByDescending<Pair<SafeSpecEntry, Long>> { it.first.clickTarget.bounds.centerX }.thenBy { it.second })
.map { it.first }
.distinctBy { it.clickTarget.path }
.toList()
@@ -281,15 +281,18 @@ class PurchaseRehearsalExecutor(
} ?: safeCandidates
if (candidates.size > 1) {
panelDiagnostic(specEntryEvidence(screen, candidates.size))
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size)}]")
}
val target = candidates.singleOrNull()?.second ?: run {
panelDiagnostic(specEntryEvidence(screen, 0))
return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口")
return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0)}]")
}
val click = driver.clickFreshDetailed(target)
when (click.result) {
FreshActionResult.AMBIGUOUS -> return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size)}]",
)
FreshActionResult.SUCCESS -> Unit
else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason())
}
@@ -367,6 +367,64 @@ class PddProductDetailCollectorTest {
assertEquals("bottom_purchase", parsed.specEntrySource)
}
@Test
fun bottomPurchaseEntriesPreferRightmostCandidate() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("buy-left", "单独购买", 446, 2000, 685, 2160, clickable = true),
node("buy-right", "发起拼单", 685, 2000, 1080, 2160, clickable = true),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertEquals("buy-right", parsed.specEntry?.path)
assertEquals("buy-right", parsed.specEntryClickTarget?.path)
assertEquals("bottom_purchase_rightmost", parsed.specEntrySource)
assertEquals(2, parsed.bottomPurchaseEntryCount)
}
@Test
fun equallyRightmostBottomPurchaseEntriesPreferSmallerArea() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("buy-large", "发起拼单", 685, 2000, 1080, 2160, clickable = true),
node("buy-small", "立即购买", 785, 2000, 980, 2140, clickable = true),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertEquals("buy-small", parsed.specEntry?.path)
assertEquals("bottom_purchase_rightmost", parsed.specEntrySource)
}
@Test
fun bottomPurchaseCandidatesStillExcludeReviewOrderAndPaymentContexts() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("reviews", "商品评价", 446, 1900, 685, 2160, clickable = true),
node("reviews/buy", "购买", 480, 2000, 650, 2100, parentPath = "reviews"),
node("order", "购买并提交订单", 685, 2000, 880, 2160, clickable = true),
node("payment", "购买后立即支付", 880, 2000, 1080, 2160, clickable = true),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertEquals(null, parsed.specEntry)
assertEquals(0, parsed.bottomPurchaseEntryCount)
}
@Test
fun bottomPurchaseInsideReviewCardIsNeverSpecEntry() {
val snapshot = UiSnapshot(
@@ -605,6 +605,29 @@ class PurchaseRehearsalExecutorTest {
assertFalse(driver.clicked.contains("商品评价"))
}
@Test
fun `spec entry failures include scalar diagnostics only`() {
val driver = FakePurchaseDriver(missingSpecEntry = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
assertEquals(
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true]",
outcome.message,
)
val ambiguousDriver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.TARGET_AMBIGUOUS)
val ambiguous = PurchaseRehearsalExecutor(ambiguousDriver, { ambiguousDriver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode)
assertEquals(
"规格入口候选不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true]",
ambiguous.message,
)
}
@Test
fun `parser keeps type only compatibility and rejects dangerous or unknown fields`() {
val legacy = """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct"}]}"""