feat(#124): add color discovery diagnostics

This commit is contained in:
QiuSW
2026-08-28 10:55:09 +08:00
parent 7b5574506a
commit 9bb19b4d0f
4 changed files with 214 additions and 7 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent" applicationId = "cn.ilapage.goauto.agent"
minSdk = 23 minSdk = 23
targetSdk = 34 targetSdk = 34
versionCode = 24 versionCode = 25
versionName = "0.9.11" versionName = "0.9.12"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -881,12 +881,53 @@ class PddProductDetailCollector(
missing: MutableSet<String>, missing: MutableSet<String>,
unsupported: MutableSet<String>, unsupported: MutableSet<String>,
): PddCollectorResult? { ): PddCollectorResult? {
val diagnosticStartedAt = now()
val diagnosticRowValues = linkedMapOf<Int, MutableSet<String>>()
val diagnosticParsedValues = linkedSetOf<String>()
var diagnosticNonClickableCandidates = 0
var diagnosticSelectedValues = 0
var diagnosticHorizontalSwipes = 0
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
fun observeColorDiscovery(screen: ParsedPddScreen, rows: List<List<VisibleSpecValue>>) {
rows.forEachIndexed { index, row ->
diagnosticRowValues.getOrPut(index) { linkedSetOf() }.addAll(row.map(VisibleSpecValue::text))
}
val values = rows.flatten()
diagnosticParsedValues += values.map(VisibleSpecValue::text)
diagnosticSelectedValues = maxOf(
diagnosticSelectedValues,
values.count { it.node.selected || it.node.checked },
)
diagnosticNonClickableCandidates = maxOf(
diagnosticNonClickableCandidates,
visibleNonClickableColorCandidateCount(screen, config, values.map(VisibleSpecValue::text).toSet()),
)
}
fun finishColorDiscovery(reason: AgentDiagnosticReason = diagnosticTermination) {
recordColorDiscovery(
reason = reason,
rowValueCounts = diagnosticRowValues.mapValues { it.value.size },
parsedValueCount = diagnosticParsedValues.size,
nonClickableCandidateCount = diagnosticNonClickableCandidates,
selectedValueCount = diagnosticSelectedValues,
horizontalSwipes = diagnosticHorizontalSwipes,
elapsedMs = now() - diagnosticStartedAt,
)
}
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it } moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
moveColorsToStart(goodsId, config, evidence, deadline)?.let { return it } moveColorsToStart(goodsId, config, evidence, deadline)?.let { return it }
val initial = parse(goodsId, config, evidence) val initial = parse(goodsId, config, evidence)
initial.problem?.let { return failure(it.code, it.message) } initial.problem?.let { return failure(it.code, it.message) }
val rowCount = colorRows(initial).size val initialRows = colorRows(initial)
if (rowCount == 0) return null observeColorDiscovery(initial, initialRows)
val rowCount = initialRows.size
if (rowCount == 0) {
finishColorDiscovery(AgentDiagnosticReason.COLOR_EDGE_REACHED)
return null
}
val attempted = mutableSetOf<String>() val attempted = mutableSetOf<String>()
for (rowIndex in 0 until rowCount) { for (rowIndex in 0 until rowCount) {
val moveRight = rowIndex % 2 == 0 val moveRight = rowIndex % 2 == 0
@@ -902,6 +943,7 @@ class PddProductDetailCollector(
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页") if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name } screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
val rows = colorRows(screen) val rows = colorRows(screen)
observeColorDiscovery(screen, rows)
if (rowIndex >= rows.size) { if (rowIndex >= rows.size) {
// Some PDD builds remove the selected option from the // Some PDD builds remove the selected option from the
// clickable accessibility nodes and immediately reflow // clickable accessibility nodes and immediately reflow
@@ -909,6 +951,7 @@ class PddProductDetailCollector(
// successful selection is therefore not evidence that // successful selection is therefore not evidence that
// its values were missed. Real gaps are still reported // its values were missed. Real gaps are still reported
// below as missing colors or prices. // below as missing colors or prices.
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
break break
} }
currentRow = rows[rowIndex].sortedBy { it.node.bounds.left } currentRow = rows[rowIndex].sortedBy { it.node.bounds.left }
@@ -937,18 +980,101 @@ class PddProductDetailCollector(
val signature = optionSignature(currentRow) val signature = optionSignature(currentRow)
stable = if (signature == previous) stable + 1 else 0 stable = if (signature == previous) stable + 1 else 0
previous = signature previous = signature
if (stable >= config.limits.getValue("stableEdgeReads") || pass == config.limits.getValue("specHorizontalSwipes")) break if (stable >= config.limits.getValue("stableEdgeReads")) {
val anchor = currentRow.firstOrNull()?.node ?: break if (diagnosticTermination == AgentDiagnosticReason.COLOR_FOUND) {
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
}
break
}
if (pass == config.limits.getValue("specHorizontalSwipes")) {
if (diagnosticTermination == AgentDiagnosticReason.COLOR_FOUND) {
diagnosticTermination = AgentDiagnosticReason.COLOR_SCAN_LIMIT
}
break
}
val anchor = currentRow.firstOrNull()?.node
if (anchor == null) {
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
break
}
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
if (!driver.swipeSpec(direction, anchor)) break if (!driver.swipeSpec(direction, anchor)) {
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
break
}
diagnosticHorizontalSwipes++
pause(350) pause(350)
} }
val naturalOrder = rowColors.entries.toList().let { if (moveRight) it else it.reversed() } val naturalOrder = rowColors.entries.toList().let { if (moveRight) it else it.reversed() }
naturalOrder.forEach { (text, available) -> colors[text] = colors[text] == true || available } naturalOrder.forEach { (text, available) -> colors[text] = colors[text] == true || available }
} }
finishColorDiscovery()
return null return null
} }
private fun recordColorDiscovery(
reason: AgentDiagnosticReason,
rowValueCounts: Map<Int, Int>,
parsedValueCount: Int,
nonClickableCandidateCount: Int,
selectedValueCount: Int,
horizontalSwipes: Int,
elapsedMs: Long,
) {
if (taskId <= 0) return
fun record(metricReason: AgentDiagnosticReason, attempt: Int = 0, count: Int) {
diagnostic(
AgentDiagnosticEvent(
taskId = taskId,
stage = AgentDiagnosticStage.COLOR_DISCOVERY,
reason = metricReason,
attempt = attempt,
elapsedMs = elapsedMs,
candidateCount = count,
),
)
}
rowValueCounts.forEach { (rowIndex, count) ->
record(AgentDiagnosticReason.COLOR_ROW_VALUE_COUNT, attempt = rowIndex + 1, count = count)
}
record(AgentDiagnosticReason.COLOR_FOUND, count = parsedValueCount)
record(AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE, count = nonClickableCandidateCount)
record(AgentDiagnosticReason.COLOR_SELECTED_VALUE_COUNT, count = selectedValueCount)
record(AgentDiagnosticReason.COLOR_HORIZONTAL_SWIPE_COUNT, count = horizontalSwipes)
record(AgentDiagnosticReason.COLOR_VERTICAL_SWIPE_COUNT, count = 0)
if (reason != AgentDiagnosticReason.COLOR_FOUND) {
record(reason, attempt = horizontalSwipes, count = parsedValueCount)
}
}
private fun visibleNonClickableColorCandidateCount(
screen: ParsedPddScreen,
config: PddCollectorConfig,
parsedLabels: Set<String>,
): Int {
val visible = screen.sourceNodes.filter(SnapshotNode::visible)
val compactColorAliases = config.colorAliases.map { it.replace(" ", "") }
val heading = visible.asSequence()
.filterNot(SnapshotNode::clickable)
.filter { node -> compactColorAliases.any { alias -> node.label.replace(" ", "").contains(alias) } }
.minByOrNull { it.bounds.top } ?: return 0
val allHeadingAliases = (config.colorAliases + config.sizeAliases + config.textAliases.dimension.exactNames +
config.textAliases.dimension.adaptiveAliases).map { it.replace(" ", "") }
val lowerBound = visible.asSequence()
.filterNot(SnapshotNode::clickable)
.filter { it.bounds.top >= heading.bounds.bottom }
.filter { node -> allHeadingAliases.any { alias -> node.label.replace(" ", "").contains(alias) } }
.minOfOrNull { it.bounds.top } ?: Int.MAX_VALUE
val paths = visible.map(SnapshotNode::path).toSet()
return visible.asSequence()
.filter { !it.clickable && it.enabled && it.label.isNotBlank() }
.filter { it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lowerBound }
.filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 }
.filterNot { it.label in parsedLabels }
.filterNot { candidate -> paths.any { it.startsWith("${candidate.path}/") } }
.count()
}
private fun moveSpecPanelToTop( private fun moveSpecPanelToTop(
goodsId: String, goodsId: String,
config: PddCollectorConfig, config: PddCollectorConfig,
@@ -9,6 +9,7 @@ import cn.ilapage.goauto.agent.BuildConfig
enum class AgentDiagnosticStage { enum class AgentDiagnosticStage {
DETAIL_ENTRY, DETAIL_ENTRY,
SPEC_PANEL_ENTRY, SPEC_PANEL_ENTRY,
COLOR_DISCOVERY,
SIZE_DISCOVERY, SIZE_DISCOVERY,
PAGE_STABILITY, PAGE_STABILITY,
SHARE_CLICK, SHARE_CLICK,
@@ -35,6 +36,17 @@ enum class AgentDiagnosticReason {
SIZE_SCAN_LIMIT, SIZE_SCAN_LIMIT,
SIZE_CONTAINER_UNAVAILABLE, SIZE_CONTAINER_UNAVAILABLE,
SIZE_SWIPE_FAILED, SIZE_SWIPE_FAILED,
COLOR_FOUND,
COLOR_EDGE_REACHED,
COLOR_SCAN_LIMIT,
COLOR_ROW_REFLOWED,
COLOR_SWIPE_FAILED,
COLOR_CONTAINER_UNAVAILABLE,
COLOR_VALUES_NOT_CLICKABLE,
COLOR_ROW_VALUE_COUNT,
COLOR_SELECTED_VALUE_COUNT,
COLOR_HORIZONTAL_SWIPE_COUNT,
COLOR_VERTICAL_SWIPE_COUNT,
PACKAGE_MISMATCH, PACKAGE_MISMATCH,
ACTIVITY_MISMATCH, ACTIVITY_MISMATCH,
SELECTOR_MISMATCH, SELECTOR_MISMATCH,
@@ -18,6 +18,7 @@ import cn.ilapage.goauto.agent.automation.UiSnapshot
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -535,6 +536,62 @@ class PddProductDetailCollectorTest {
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null }) assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null })
} }
@Test
fun `color discovery records only bounded aggregate evidence`() {
val colors = listOf("A色", "B色", "C色", "D色")
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeCollectorDriver(
colors = colors,
prices = colors.associateWith { 1000L },
rowSize = 2,
nonClickableColorCandidates = listOf("隐藏候选"),
)
var clock = 0L
val result = PddProductDetailCollector(
driver,
{ clock },
{ clock += it },
taskId = 124,
diagnostic = events::add,
).collect(GOODS_ID, rule())
assertTrue(result.successful)
val colorEvents = events.filter { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(listOf(2, 2), colorEvents.filter { it.reason == AgentDiagnosticReason.COLOR_ROW_VALUE_COUNT }.map { it.candidateCount })
assertEquals(4, colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_FOUND }.candidateCount)
assertEquals(1, colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE }.candidateCount)
assertEquals(1, colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_SELECTED_VALUE_COUNT }.candidateCount)
assertTrue(requireNotNull(colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_HORIZONTAL_SWIPE_COUNT }.candidateCount) > 0)
assertEquals(0, colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_VERTICAL_SWIPE_COUNT }.candidateCount)
assertTrue(colorEvents.any { it.reason == AgentDiagnosticReason.COLOR_EDGE_REACHED })
val persistedText = colorEvents.joinToString()
colors.forEach { assertFalse(persistedText.contains(it)) }
assertFalse(persistedText.contains("隐藏候选"))
assertFalse(persistedText.contains("10.00"))
}
@Test
fun `color diagnostic write failure does not change collection result`() {
var failureCount = 0
val recorder = SafeAgentDiagnosticRecorder(
persist = { error("diagnostic database unavailable") },
onFailure = { failureCount++ },
)
var clock = 0L
val result = PddProductDetailCollector(
FakeCollectorDriver(),
{ clock },
{ clock += it },
taskId = 124,
diagnostic = recorder::record,
).collect(GOODS_ID, rule())
assertTrue(result.successful)
assertTrue(failureCount > 0)
}
@Test @Test
fun selectedColorsDisappearingAndReflowingDoNotCreateFalseMissingRows() { fun selectedColorsDisappearingAndReflowingDoNotCreateFalseMissingRows() {
val colors = listOf("A色", "B色", "C色") val colors = listOf("A色", "B色", "C色")
@@ -1093,6 +1150,7 @@ class PddProductDetailCollectorTest {
private val orderConfirmationAfterEntry: Boolean = false, private val orderConfirmationAfterEntry: Boolean = false,
private val orderRestorePagesBeforeSpecs: Int = 0, private val orderRestorePagesBeforeSpecs: Int = 0,
private val sizeHeadingLabel: String = "尺码", private val sizeHeadingLabel: String = "尺码",
private val nonClickableColorCandidates: List<String> = emptyList(),
) : PddCollectorDriver { ) : PddCollectorDriver {
var captureCount = 0 var captureCount = 0
var clickCount = 0 var clickCount = 0
@@ -1182,6 +1240,17 @@ class PddProductDetailCollectorTest {
parentPath = "scroll", parentPath = "scroll",
) )
} }
nonClickableColorCandidates.forEachIndexed { index, label ->
nodes += node(
"scroll/non-clickable-color-$index",
label,
30 + index * 230,
650,
220 + index * 230,
690,
parentPath = "scroll",
)
}
} }
if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, 700, 300, 750, parentPath = "scroll") if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, 700, 300, 750, parentPath = "scroll")
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size -> sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->